diff --git a/BaseLib/images/ui/tempHP.png b/BaseLib/images/ui/tempHP.png new file mode 100644 index 00000000..d5180b5b Binary files /dev/null and b/BaseLib/images/ui/tempHP.png differ diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json index fe1ea886..23594e98 100644 --- a/BaseLib/localization/eng/static_hover_tips.json +++ b/BaseLib/localization/eng/static_hover_tips.json @@ -8,6 +8,8 @@ "BASELIB-REFUND.title": "Refund", "BASELIB-REFUND.description": "When energy is spent on this card, up to [blue]X[/blue] of that energy is refunded.", "BASELIB-REFUND.smartDescription": "When energy is spent on this card, up to [blue]{Refund}[/blue] of that energy is refunded.", + "BASELIB-VITALITY.title": "Vitality", + "BASELIB-VITALITY.description": "Until the end of combat, prevents HP loss.", "BASELIB-SCRY.title": "Scry", "BASELIB-SCRY.description": "Look at the top [blue]X[/blue] cards of your [gold]Draw Pile[/gold]. You may discard any of them.", "BASELIB-SCRY.smartDescription": "Look at the top {Scry:plural:card|[blue]{Scry:diff()}[/blue] cards} of your [gold]Draw Pile[/gold]. You may discard {Scry:plural:it|any of them}." diff --git a/Cards/Variables/VitalityVar.cs b/Cards/Variables/VitalityVar.cs new file mode 100644 index 00000000..cdb733c8 --- /dev/null +++ b/Cards/Variables/VitalityVar.cs @@ -0,0 +1,49 @@ +using BaseLib.Extensions; +using BaseLib.Hooks; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Localization.DynamicVars; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Cards.Variables; + +/// +/// Represents a dynamic variable for the "Vitality" keyword, responsible for calculating +/// and updating the vitality value shown on card previews, including active combat modifiers. +/// +public class VitalityVar : DynamicVar +{ + /// + /// Creates a new vitality variable named "Vitality" and registers its hover tooltip + /// via , which resolves the + /// BASELIB-VITALITY localization entries (preferring .smartDescription when available). + /// + /// The base vitality amount before modifiers are applied. + public VitalityVar(decimal baseValue) : base("Vitality", baseValue) + { + this.WithTooltip(); + } + + /// + /// Updates the preview value of the vitality variable on the card. + /// Passes the current integer value through global vitality modification hooks if enabled. + /// + /// The card model displaying this variable. + /// The mode dictating how the card preview is rendered. + /// The target creature of the card action, if any. + /// + /// If , routes the base value through + /// to account for relics, powers, or status effects that alter vitality counts. + /// + public override void UpdateCardPreview( + CardModel card, + CardPreviewMode previewMode, + Creature? target, + bool runGlobalHooks) + { + var vitalityAmount = BaseValue; + if (runGlobalHooks) + vitalityAmount = BaseLibHooks.ModifyVitalityAmount(card.CombatState, card.Owner.Creature, BaseValue, card, null, out _); + PreviewValue = vitalityAmount; + } +} \ No newline at end of file diff --git a/Commands/VitalityCmd.cs b/Commands/VitalityCmd.cs new file mode 100644 index 00000000..aa76feb3 --- /dev/null +++ b/Commands/VitalityCmd.cs @@ -0,0 +1,100 @@ +using BaseLib.Hooks; +using BaseLib.Patches.Features; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Combat.History; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Players; + +namespace BaseLib.Commands; + +/// +/// Command utility responsible for executing the vitality mechanic process, including +/// hook modification, finding current values, and combatHistory entries. +/// +public class VitalityCmd +{ + /// Make this creature gain the specified amount of vitality. + /// Creature that should gain vitality. + /// Amount of vitality they should gain. + /// + /// The CardPlay that caused the vitality gain. + /// Null if it was not directly caused by a card play. + /// + /// If true, the wait that is performed after vitality gain is very small. Should be used in scenarios + /// where vitality gain happens quickly in sequence (i.e. like Afterimage for block). + /// The amount of vitality that the creature gained after all modifications were applied. + public static async Task GainVitality( + Creature creature, + decimal amount, + CardPlay? cardPlay, + bool fast = false) + { + if (CombatManager.Instance.IsOverOrEnding) + return 0M; + var combatState = creature.CombatState; + await BaseLibHooks.BeforeVitalityGained(combatState, creature, amount, cardPlay?.Card); + var modifiedAmount = BaseLibHooks.ModifyVitalityAmount(combatState, creature, amount, cardPlay?.Card, cardPlay, out var modifiers); + modifiedAmount = Math.Max(modifiedAmount, 0M); + await BaseLibHooks.AfterModifyingVitalityAmount(combatState, modifiedAmount, cardPlay, modifiers); + if (modifiedAmount > 0M) + { + SfxCmd.Play("event:/sfx/heal"); + VfxCmd.PlayOnCreatureCenter(creature, "vfx/vfx_cross_heal"); + VitalityPatch.VitalityField.SetVitality(creature, (int) amount + VitalityPatch.VitalityField.GetVitality(creature)); + CombatManager.Instance.History.Add(combatState, new VitalityGainedEntry((int)modifiedAmount, cardPlay, creature, combatState.RoundNumber, combatState.CurrentSide, CombatManager.Instance.History, combatState.Players)); + if (fast) + await Cmd.CustomScaledWait(0.0f, 0.03f); + else + await Cmd.CustomScaledWait(0.1f, 0.25f); + } + await BaseLibHooks.AfterVitalityGained(combatState, creature, modifiedAmount, cardPlay?.Card); + return modifiedAmount; + } + + /// Returns the amount vitality a specific creature has. + /// Creature you're trying to get the vitality amount of. + /// The amount of vitality that the creature has currently. + public static decimal Get(Creature creature) + { + return VitalityPatch.VitalityField.GetVitality(creature); + } + + /// Removes all vitality a specific creature has. + /// Creature you're trying to remove the vitality from. + public static void RemoveAll(Creature creature) + { + VitalityPatch.VitalityField.SetVitality(creature, 0); + } + + private class VitalityGainedEntry : CombatHistoryEntry + { + public int Amount { get; } + + public Creature Receiver => Actor; + + public CardPlay? CardPlay { get; } + + public override string Description => $"{GetId(Receiver)} gained {Amount} vitality"; + + public VitalityGainedEntry( + int amount, + CardPlay? cardPlay, + Creature receiver, + int roundNumber, + CombatSide currentSide, + CombatHistory history, + IEnumerable players) + : base(receiver, roundNumber, currentSide, history, players) + { + Amount = amount; + CardPlay = cardPlay; + } + + private static string GetId(Creature creature) + { + return !creature.IsPlayer ? creature.Monster.Id.Entry : creature.Player.Character.Id.Entry; + } + } +} \ No newline at end of file diff --git a/Extensions/DynamicVarSetExtensions.cs b/Extensions/DynamicVarSetExtensions.cs index cc28a648..640b5cc9 100644 --- a/Extensions/DynamicVarSetExtensions.cs +++ b/Extensions/DynamicVarSetExtensions.cs @@ -56,4 +56,11 @@ public static ScryVar Scry(this DynamicVarSet vard) return (ScryVar)vard._vars[nameof(Scry)]; } + /// + /// Get the Vitality var initialized with its default name. + /// + public static VitalityVar Vitality(this DynamicVarSet vard) + { + return (VitalityVar)vard._vars[nameof(Vitality)]; + } } \ No newline at end of file diff --git a/Hooks/BaseLibHooks.cs b/Hooks/BaseLibHooks.cs index 5d1941f9..a8a8bcd3 100644 --- a/Hooks/BaseLibHooks.cs +++ b/Hooks/BaseLibHooks.cs @@ -1,6 +1,8 @@ using BaseLib.Abstracts; using BaseLib.Utils; using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.GameActions.Multiplayer; using MegaCrit.Sts2.Core.Models; @@ -128,4 +130,107 @@ public static decimal ModifyResourceCostInCombat( out _); return modifiedCost; } + + /// + /// Passes a scry amount through all hook listeners in + /// listener order, letting each adjust the value, and reports which listeners changed it. + /// + /// A listener counts as a modifier only if it changed the value it received + /// (per-step equality): returning the input unchanged does not + /// register it in . The comparison is per listener, not + /// against the original amount. + /// Listeners whose changes cancel each other out + /// (+2 then −2) are all recorded, so can be + /// non-empty even when the returned amount equals . + /// + /// + /// Outside of combat (no combat state), returns unchanged + /// with an empty set. + /// + /// + /// ICombatState of the creature gaining Vitality. + /// The creature gaining vitality. + /// The original vitality amount before modifiers. + /// Passes the cardSource if there is one. + /// Similarly passes the cardPlay if there is one. + /// + /// The listeners that changed the value, for follow-up dispatch via + /// . + /// + /// + /// The final Vitality amount. Not clamped: may be zero or negative if listeners reduce it; + /// callers are expected to treat non-positive results as "no vitality" or clamp it themselves. + /// + public static decimal ModifyVitalityAmount( + ICombatState combatState, + Creature creature, + decimal amount, + CardModel? cardSource, + CardPlay? cardPlay, + out IEnumerable modifiers) + { + var additive = HookUtils.Modify(combatState, amount, (m, a) => m.ModifyVitalityAdditive(creature, a, cardSource, cardPlay), out modifiers); + return HookUtils.ModifyMultiplicative(combatState, additive, modifiers, (m, a) => m.ModifyVitalityMultiplicative(creature, a, cardSource, cardPlay), out modifiers); + } + + /// + /// Dispatches to each listener + /// that changed the vitality amount in the preceding call - + /// e.g. to play VFX or consume a charge. + /// + /// Listeners that saw the value but left it unchanged are not called. Iteration + /// follows current hook-listener order, not the order of + /// . + /// + /// + /// ICombatState of the creature gaining Vitality. + /// The original vitality amount before modifiers. + /// Similarly passes the cardSource if there is one. + /// + /// The listeners that changed the value, for follow-up dispatch via + /// . + /// + public static Task AfterModifyingVitalityAmount( + ICombatState combatState, + decimal amount, + CardPlay? cardPlay, + IEnumerable modifiers) + { + return HookUtils.AfterModifying(combatState, modifiers, a => a.AfterModifyingVitalityAmount(amount, cardPlay)); + } + + + /// + /// Dispatches to + /// all subscribed models before the Vitality is given. + /// + /// ICombatState of the creature gaining Vitality. + /// The creature gaining vitality. + /// The vitality amount BEFORE modifiers. + /// Gives source card if there is one. + public static Task BeforeVitalityGained( + ICombatState combatState, + Creature creature, + decimal amount, + CardModel? cardSource) + { + return HookUtils.Dispatch(combatState, a => a.BeforeVitalityGained(creature, amount, cardSource)); + } + + /// + /// Dispatches to + /// all subscribed models after the Vitality is given. + /// + /// ICombatState of the creature gaining Vitality. + /// The creature gaining vitality. + /// The vitality amount after modifiers. + /// Gives source card if there is one. + public static Task AfterVitalityGained( + ICombatState combatState, + Creature creature, + decimal amount, + CardModel? cardSource) + { + return HookUtils.Dispatch(combatState, a => a.AfterVitalityGained(creature, amount, cardSource)); + } } \ No newline at end of file diff --git a/Hooks/IModifyVitalityAmount.cs b/Hooks/IModifyVitalityAmount.cs new file mode 100644 index 00000000..1f1779a9 --- /dev/null +++ b/Hooks/IModifyVitalityAmount.cs @@ -0,0 +1,77 @@ +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Hooks; + +/// +/// Hook for models (relics, powers, stances, ...) that adjust the amount of vitality given. +/// Listeners are invoked in hook-listener order via +/// before it resolves. +/// +public interface IModifyVitalityAmount +{ + /// + /// Returns the adjusted vitality amount. Called once per pending vitality, receiving the value as + /// modified by any earlier listeners. Always runs before as to not cause ordering issues. + /// + /// Return unchanged to opt out — doing so also excludes + /// this listener from the follow-up. Changing + /// the value (per-call equality) marks this listener as a modifier + /// even if a later listener cancels the change. + /// + /// + /// Results are not clamped. This method must be pure with respect to game state - + /// put side effects (VFX, charge consumption) in + /// instead, which only runs when a change was actually made. + /// + /// + /// The creature gaining vitality. + /// + /// The vitality amount so far, including earlier listeners' changes. + /// This value is clamped to a minimum of zero. + /// + /// Passes the cardSource if there is one. + /// Similarly passes the cardPlay if there is one. + /// The new vitality amount; may be lower, higher, or unchanged. + decimal ModifyVitalityAdditive(Creature creature, decimal amount, CardModel? cardSource, CardPlay? cardPlay) => 0m; + + /// + /// Follow-up invoked after all listeners have run, but only on listeners whose + /// or changed the value they received. Use this for the + /// side effects of having modified the vitality: visuals, sounds, consuming charges, + /// decrementing counters. + /// + /// The amounts describe the whole modification pass, not this listener's step: + /// every invoked listener receives the same pair, and + /// includes other listeners' changes. A listener + /// wanting its own delta must capture the values it saw in + /// or themselves. + /// + /// + /// The original vitality amount before or modifiers. + /// Similarly passes the cardSource if there is one. + Task AfterModifyingVitalityAmount(decimal amount, CardPlay? cardPlay) => Task.CompletedTask; + + /// + /// Returns a multiplier to alter the vitality amount by. Called once per pending vitality addition, receiving the value as + /// modified by any earlier listeners. Always runs after as to not cause ordering issues. + /// + /// Return unchanged to opt out — doing so also excludes + /// this listener from the follow-up. Changing + /// the value (per-call equality) marks this listener as a modifier + /// even if a later listener cancels the change. + /// + /// + /// Results are not clamped. This method must be pure with respect to game state - + /// put side effects (VFX, charge consumption) in + /// instead, which only runs when a change was actually made. + /// + /// + /// The creature gaining vitality. + /// The vitality amount so far, including earlier listeners' changes. + /// Passes the cardSource if there is one. + /// Similarly passes the cardPlay if there is one. + /// The multiplier to alter the current vitality amount by. + decimal ModifyVitalityMultiplicative(Creature creature, decimal amount, CardModel? cardSource, CardPlay? cardPlay) => 1m; +} \ No newline at end of file diff --git a/Hooks/IVitalityHooks.cs b/Hooks/IVitalityHooks.cs new file mode 100644 index 00000000..21a80f14 --- /dev/null +++ b/Hooks/IVitalityHooks.cs @@ -0,0 +1,52 @@ +using BaseLib.Commands; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Hooks; + +/// +/// Defines a hook listener that runs automatically before and after a vitality cmd has fully resolved. +/// +/// Hook interfaces should be implemented on subclasses in the active +/// combat state to be picked up by the central dispatch pipelines. +/// +/// +public interface IVitalityHooks +{ + /// + /// Invoked before has fully completed. + /// + /// + /// + /// State Timing: When this method runs, vitality has not yet been added nor have any listeners + /// altered its values. + /// + /// + /// The creature gaining vitality. + /// The vitality amount so far, including earlier listeners' changes. + /// Passes the cardSource if there is one. + /// A tracking the asynchronous execution of this follow-up hook logic. + Task BeforeVitalityGained (Creature creature, decimal amount, CardModel? cardSource) => Task.CompletedTask; + + /// + /// Invoked after has fully completed. + /// + /// + /// + /// This method will run regardless of the final value of the vitality given, even if it's zero. + /// Listeners are expected. + /// + /// + /// State Timing: When this method runs, vitality has been added, and + /// hooks have already ran. + /// + /// + /// The creature gaining vitality. + /// + /// The vitality amount so far, including earlier listeners' changes. + /// This value is clamped to a minimum of zero. + /// + /// Passes the cardSource if there is one. + /// A tracking the asynchronous execution of this follow-up hook logic. + Task AfterVitalityGained(Creature creature, decimal amount, CardModel? cardSource) => Task.CompletedTask; +} \ No newline at end of file diff --git a/Patches/Features/VitalityPatch.cs b/Patches/Features/VitalityPatch.cs new file mode 100644 index 00000000..c532cd70 --- /dev/null +++ b/Patches/Features/VitalityPatch.cs @@ -0,0 +1,377 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Hooks; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Multiplayer; +using MegaCrit.Sts2.Core.Nodes.Vfx; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Settings; + +namespace BaseLib.Patches.Features; + +public static class VitalityPatch +{ + + private static readonly Color BlockOutlineColor = new("B4E2FF"); + private static readonly Color VitalityHeartColor = new ("FFC800"); + private static readonly Color VitalityTextOutlineColor = StsColors.rewardLabelGoldOutline; + + public static class VitalityField + { + /// + /// !!IMPORTANT!! If intending to change this value, use SetVitality to avoid issues. + /// + private static readonly SpireField TemporaryHp = new(() => 0); + + public static readonly SpireField?> VitalityChanged = new(() => null); + public static readonly SpireField VitalityTween = new(() => null); + internal static void SetVitality(Creature creature, int value) + { + if (value < 0) + throw new ArgumentException("Vitality must be positive ", nameof (value)); + if (TemporaryHp.Get(creature) == value) + return; + var tempHp = TemporaryHp.Get(creature); + TemporaryHp.Set(creature, value); + var vitalityChanged = VitalityChanged.Get(creature); + vitalityChanged?.Invoke(tempHp, TemporaryHp.Get(creature), creature); + } + + internal static int GetVitality(Creature creature) + { + return TemporaryHp.Get(creature); + } + } + + [HarmonyPatch(typeof(Creature), nameof(Creature.LoseHpInternal))] + public class HpInterceptPatch + { + static IEnumerable Transpiler(IEnumerable instructions) + { + var codeMatcher = new CodeMatcher(instructions); + MethodInfo getCurrentHpInfo = AccessTools.PropertyGetter(typeof(Creature), nameof(Creature.CurrentHp)); + + MethodInfo tempHp = AccessTools.Method(typeof(HpInterceptPatch), nameof(TemporaryHpHandler)); + + codeMatcher.MatchStartForward( + new CodeMatch(OpCodes.Ldarg_0), + new CodeMatch(OpCodes.Ldarg_0), + new CodeMatch(OpCodes.Call, getCurrentHpInfo) + ) + .ThrowIfInvalid("Couldn't find getCurrentHp method for TemporaryHpHandler") + .InsertAndAdvance( + new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Ldloc_2), + new CodeInstruction(OpCodes.Call, tempHp), + new CodeInstruction(OpCodes.Stloc_2) + ); + + return codeMatcher.InstructionEnumeration(); + } + + private static int TemporaryHpHandler(Creature c, int num) + { + var tempHp = VitalityField.GetVitality(c); + if (num >= tempHp) + { + num -= tempHp; + VitalityField.SetVitality(c, 0); + } + else + { + VitalityField.SetVitality(c, tempHp - num); + num = 0; + } + + var absorbed = tempHp - VitalityField.GetVitality(c); + if(absorbed > 0) PlayAbsorbFx(c, absorbed); + + return num; + } + } + + [HarmonyPatch(typeof(Hook))] + [HarmonyPatch(nameof(Hook.AfterCombatEnd))] + public class CombatEndPatch + { + static void Postfix(ICombatState? combatState) + { + foreach (Creature c in combatState?.Creatures) + { + VitalityField.SetVitality(c, 0); + } + } + } + + // Visual Effect patching begins below. + // Could use either this method (overriding base-game textures to make it appear correctly) + // or just copying and creating a new instance for each. + + + // this was just directly taken from the other PR because I was lazy. Not a particularly difficult to make on my own just didn't feel the need to bother. + /// + /// Gold floating number for absorbed damage — the only feedback on a fully absorbed hit, since vanilla + /// shows no damage number and no hurt anim when the final HP loss is 0. + /// + internal static void PlayAbsorbFx(Creature target, int absorbed) + { + if (absorbed <= 0 || !CombatManager.Instance.IsInProgress) + return; + var vfx = NDamageNumVfx.Create(target, absorbed); + if (vfx == null) + return; + vfx.Modulate = StsColors.gold; // the _Ready tween animates modulate gold -> cream, mimicking vanilla's red -> cream + var label = vfx.GetNodeOrNull("Label"); + label?.AddThemeColorOverride("font_color", StsColors.gold); + label?.AddThemeColorOverride("font_outline_color", StsColors.rewardLabelGoldOutline); + var container = target.GetVfxContainer(); + if (container != null) + container.AddChildSafely(vfx); + else + NRun.Instance?.GlobalUi.AddChildSafely(vfx); + } + + [HarmonyPatch(typeof(NHealthBar), "RefreshBlockUi")] + public class TempHpOutline + { + [HarmonyPostfix] + public static void SelfModulateOutline(Creature ____creature, Control ____blockOutline) + { + if (____creature.Block > 0 || VitalityField.GetVitality(____creature) <= 0) + { + ____blockOutline.SelfModulate = Colors.White; + return; + } + + ____blockOutline.Visible = true; + var color = VitalityHeartColor; + // Altering colors to avoid touching the modulate, but making the color appear correctly. + // Could use snowlie's method of rendering instead + color.R8 += 255 - BlockOutlineColor.R8; + color.G8 += 255 - BlockOutlineColor.G8; + color.B8 += 255 - BlockOutlineColor.B8; + ____blockOutline.SelfModulate = color; + } + } + + [HarmonyPatch(typeof(NHealthBar), "RefreshText")] + public class VitalityText + { + + [HarmonyPostfix] + public static void SelfModulateOutline(Creature ____creature, MegaLabel ____hpLabel) + { + if (____creature.Block > 0 || VitalityField.GetVitality(____creature) <= 0) + return; + + ____hpLabel.AddThemeColorOverride(ThemeConstants.Label.FontColor, NHealthBar._defaultFontColor); + ____hpLabel.AddThemeColorOverride(ThemeConstants.Label.FontOutlineColor, VitalityTextOutlineColor); + } + } + + // Code courtesy of CanYou with alterations. + [HarmonyPatch] + public static class VitalityHealthBarPatch + { + public static readonly Dictionary VitalityUi = new(); + public static readonly Dictionary CreatureHealthBar = new(); + + [HarmonyPatch(typeof(NHealthBar), nameof(NHealthBar.SetCreature))] + [HarmonyPostfix] + public static void CreateVitalityUi(NHealthBar __instance, Control ____blockContainer) + { + var vitalityContainer = (Control)____blockContainer.Duplicate(); + vitalityContainer.Visible = false; + vitalityContainer.Name = "VitalityContainer"; + + // Swap block icon for heart, tinted yellow + var icon = vitalityContainer.GetNode("BlockIcon"); + icon.Texture = PreloadManager.Cache.GetTexture2D("BaseLib/images/ui/tempHP.png"); + icon.SelfModulate = VitalityHeartColor; + + var label = vitalityContainer.GetNode("BlockLabel"); + label.AddThemeColorOverride(ThemeConstants.Label.FontOutlineColor, VitalityTextOutlineColor); + + __instance.HpBarContainer.AddChild(vitalityContainer); + vitalityContainer.SetAnchorsPreset(Control.LayoutPreset.CenterLeft, true); + + // Mirror to the right side + vitalityContainer.Position = new Vector2( + __instance.HpBarContainer.Size.X - vitalityContainer.Size.X, + ____blockContainer.Position.Y); + + CreatureHealthBar[__instance._creature] = __instance; + VitalityUi[__instance] = (vitalityContainer, label); + } + + [HarmonyPatch(typeof(NHealthBar), "RefreshBlockUi")] + [HarmonyPostfix] + public static void RefreshVitalityUi(NHealthBar __instance, Creature ____creature) + { + if (!VitalityUi.TryGetValue(__instance, out var ui)) return; + + if (VitalityField.GetVitality(____creature) > 0) + { + ui.container.Visible = true; + ui.label.SetTextAutoSize(((int)VitalityField.GetVitality(____creature)).ToString()); + } + else + { + ui.container.Visible = false; + } + } + + [HarmonyPatch(typeof(NHealthBar), "SetHpBarContainerSizeWithOffsetsImmediately")] + [HarmonyPostfix] + public static void SetUpVitalityOffset(NHealthBar __instance) + { + if (!VitalityUi.TryGetValue(__instance, out var ui)) return; + + ui.container.Position = new Vector2( + __instance.HpBarContainer.Size.X - ui.container.Size.X + 9f, + __instance._blockContainer.Position.Y); + } + } + + /* Disabled Vitality Healthbar due to janky issues with other Health Bar mechanics. + Could potentially happen in the future but it isn't enough effort to be worth it imo. + // Enables a Vitality "Overflow" on the bar where if it loops over it changes colors. Subject to change. + private static readonly Color[] HbColors = + [Colors.Gold, Colors.Green, Colors.MediumAquamarine, Colors.MediumVioletRed]; + + private static Color HealthBarColors(int i) => i > HbColors.Length - 1 ? HbColors[^1] : HbColors[i]; + public class VitalityForecast : IHealthBarForecastSource + { + public IEnumerable GetHealthBarForecastSegments(HealthBarForecastContext context) + { + var list = new List(); + for (var i = 0; i <= VitalityField.GetVitality(context.Creature) / context.Creature.CurrentHp; i++) + { + list.Add(new HealthBarForecastSegment( + (int)VitalityField.GetVitality(context.Creature) - context.Creature.CurrentHp * i, + HealthBarColors(i), HealthBarForecastDirection.FromLeft, -i)); + } + return list; + } + } */ + + public static void AnimateInVitality(int oldVitality, int vitalityGain, Creature creature) + { + AnimateInVitality(oldVitality, vitalityGain, VitalityHealthBarPatch.CreatureHealthBar[creature]); + } + + public static void AnimateInVitality(int oldVitality, int vitalityGain, NHealthBar healthBar) + { + if (oldVitality != 0 || vitalityGain == 0) + return; + if (!VitalityHealthBarPatch.VitalityUi.TryGetValue(healthBar, out var ui)) return; + ui.container.Visible = true; + if (SaveManager.Instance.PrefsSave.FastMode == FastModeType.Instant) + return; + var originalPosition = ui.container.Position = new Vector2( + healthBar.HpBarContainer.Size.X - ui.container.Size.X + 9f, + healthBar._blockContainer.Position.Y); + ui.container.Modulate = StsColors.transparentWhite; + ui.container.Position = originalPosition - NHealthBar._blockAnimOffset; + VitalityField.VitalityTween.Get(healthBar._creature)?.Kill(); + VitalityField.VitalityTween.Set(healthBar._creature, healthBar.CreateTween().SetParallel()); + VitalityField.VitalityTween.Get(healthBar._creature)? + .TweenProperty(ui.container, (NodePath)"modulate:a", 1f, 0.5).SetEase(Tween.EaseType.Out) + .SetTrans(Tween.TransitionType.Sine); + VitalityField.VitalityTween.Get(healthBar._creature)? + .TweenProperty(ui.container, (NodePath)"position", originalPosition, 0.5) + .SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back); + if (healthBar._creature.IsPlayer) healthBar.RefreshValues(); + } + + [HarmonyPatch] + public class NMultiplayerPlayerStatePatch + { + [HarmonyPatch(typeof(NMultiplayerPlayerState))] + [HarmonyPatch("_Ready")] + public class _ReadyPatch + { + static void Postfix(NMultiplayerPlayerState __instance) + { + VitalityField.VitalityChanged.Set(__instance.Player.Creature, + VitalityField.VitalityChanged.Get(__instance.Player.Creature) + AnimateInVitality); + } + } + + [HarmonyPatch(typeof(NMultiplayerPlayerState))] + [HarmonyPatch("_ExitTree")] + public class _ExitTreePatch + { + static void Postfix(NMultiplayerPlayerState __instance) + { + VitalityField.VitalityChanged.Set(__instance.Player.Creature, + VitalityField.VitalityChanged.Get(__instance.Player.Creature) - AnimateInVitality); + } + } + } + + [HarmonyPatch] + public class NCreatureStateDisplayPatch + { + [HarmonyPatch(typeof(NCreatureStateDisplay))] + [HarmonyPatch("SubscribeToCreatureEvents")] + public class SubscribeToCreatureEventsPatch + { + static void Postfix(NCreatureStateDisplay __instance) + { + if (__instance._creature == null) return; + VitalityField.VitalityChanged.Set(__instance._creature, + VitalityField.VitalityChanged.Get(__instance._creature) + AnimateInVitality); + } + } + + [HarmonyPatch(typeof(NCreatureStateDisplay))] + [HarmonyPatch("_ExitTree")] + public class _ExitTreePatch + { + static void Postfix(NCreatureStateDisplay __instance) + { + if (__instance._creature == null) return; + VitalityField.VitalityChanged.Set(__instance._creature, + VitalityField.VitalityChanged.Get(__instance._creature) - AnimateInVitality); + } + } + } + + [HarmonyPatch] + public class CombatStateTrackerPatch + { + [HarmonyPatch(typeof(CombatStateTracker))] + [HarmonyPatch("Subscribe")] + [HarmonyPatch([typeof(Creature)])] + public class SubscribePatch + { + static void Postfix(Creature creature) + { + VitalityField.VitalityChanged.Set(creature, + VitalityField.VitalityChanged.Get(creature) + AnimateInVitality); + } + } + + [HarmonyPatch(typeof(CombatStateTracker))] + [HarmonyPatch("Unsubscribe")] + [HarmonyPatch([typeof(Creature)])] + public class UnsubscribePatch + { + static void Postfix(Creature creature) + { + VitalityField.VitalityChanged.Set(creature, + VitalityField.VitalityChanged.Get(creature) - AnimateInVitality); + } + } + } +} \ No newline at end of file diff --git a/Utils/HookUtils.cs b/Utils/HookUtils.cs index 0cc71341..4bb38c91 100644 --- a/Utils/HookUtils.cs +++ b/Utils/HookUtils.cs @@ -4,6 +4,7 @@ using MegaCrit.Sts2.Core.GameActions.Multiplayer; using MegaCrit.Sts2.Core.Hooks; using MegaCrit.Sts2.Core.Models; +using Microsoft.CSharp.RuntimeBinder; namespace BaseLib.Utils; @@ -226,6 +227,63 @@ public static TValue Modify( if (!previous.Equals(amount)) abstractModelList.Add(model); } + modifiers = abstractModelList; + return amount; + } + + /// + /// Passes a value through all hook listeners of type , + /// tracking which listeners changed it. Specifically made for multiplicative Hooks + /// to mimic the behavior of Vanilla's multiplicative hooks. + /// + /// The hook interface to filter listeners by. + /// The type of the value being modified. Must implement . + /// The current combat state to iterate listeners from. + /// The initial value before any modifications. + /// Modifiers + /// A function that takes a listener and the current value and returns a multiplier to modify the value by. Runs after additive modifier. + /// + /// Outputs the listeners whose call changed the value they received (per-step + /// equality). Listeners returning their input unchanged are + /// excluded; listeners whose changes later cancel out are included, so this set can + /// be non-empty even when the returned value equals . + /// + /// + /// The final modified value. When is + /// , returns with an empty + /// set. + /// + public static TValue ModifyMultiplicative( + ICombatState? combatState, + TValue originalAmount, + IEnumerable? previousModifiers, + Func multiplicativeModifier, + out IEnumerable modifiers) + where THook : class + where TValue : IEquatable + { + if (combatState == null) + { + modifiers = []; + return originalAmount; + } + var amount = originalAmount; + var abstractModelList = previousModifiers != null ? previousModifiers.ToList() : []; + foreach (var model in Hook.IterateCombatHookListeners(combatState).OfType()) + { + var previous = amount; + try + { + var multiplier = multiplicativeModifier.Invoke(model, amount) as dynamic; + amount *= multiplier; + } + catch(RuntimeBinderException ex) + { + BaseLibMain.Logger.Error("Error with dynamic in Modify multiplicativeModifier: " + ex.Message); + } + if (!previous.Equals(amount)) + abstractModelList.Add(model); + } modifiers = abstractModelList; return amount;