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;