From 86c5607b5a615ef0dbc66cb550d18f275f53008a Mon Sep 17 00:00:00 2001 From: SoytheProton Date: Wed, 15 Apr 2026 17:33:45 -0700 Subject: [PATCH 1/6] Add files via upload --- Commands/VitalityCmd.cs | 164 ++++++++++++ Commands/VitalityCmd.cs.uid | 1 + Hooks/IVitalityAmountModifier.cs | 27 ++ Hooks/IVitalityAmountModifier.cs.uid | 1 + Hooks/IVitalityHooks.cs | 34 +++ Patches/VitalityPatch.cs | 382 +++++++++++++++++++++++++++ Patches/VitalityPatch.cs.uid | 1 + 7 files changed, 610 insertions(+) create mode 100644 Commands/VitalityCmd.cs create mode 100644 Commands/VitalityCmd.cs.uid create mode 100644 Hooks/IVitalityAmountModifier.cs create mode 100644 Hooks/IVitalityAmountModifier.cs.uid create mode 100644 Hooks/IVitalityHooks.cs create mode 100644 Patches/VitalityPatch.cs create mode 100644 Patches/VitalityPatch.cs.uid diff --git a/Commands/VitalityCmd.cs b/Commands/VitalityCmd.cs new file mode 100644 index 00000000..ee62ed2f --- /dev/null +++ b/Commands/VitalityCmd.cs @@ -0,0 +1,164 @@ +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Combat.History; +using MegaCrit.Sts2.Core.Combat.History.Entries; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.ValueProps; +using TestMod.TestModCode.Hooks; +using TestMod.TestModCode.Patches; + +namespace TestMod.TestModCode.Commands; + +public class VitalityCmd +{ + public static async Task GainVitality( + Creature creature, + Decimal amount, + CardPlay? cardPlay, + bool fast = false) + { + if (CombatManager.Instance.IsOverOrEnding) + return 0M; + CombatState combatState = creature.CombatState; + await BeforeVitalityGained(combatState, creature, amount, cardPlay?.Card); + Decimal modifiedAmount = amount; + IEnumerable modifiers; + modifiedAmount = ModifyVitality(combatState, creature, modifiedAmount, cardPlay.Card, cardPlay, out modifiers); + modifiedAmount = Math.Max(modifiedAmount, 0M); + await AfterModifyingVitalityAmount(combatState, modifiedAmount, cardPlay?.Card, 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(new VitalityGainedEntry((int)modifiedAmount, cardPlay, creature, combatState.RoundNumber, combatState.CurrentSide, CombatManager.Instance.History)); + if (fast) + await Cmd.CustomScaledWait(0.0f, 0.03f); + else + await Cmd.CustomScaledWait(0.1f, 0.25f); + } + await AfterVitalityGained(combatState, creature, modifiedAmount, cardPlay?.Card); + return modifiedAmount; + } + + static decimal ModifyVitality( + CombatState combatState, + Creature creature, + Decimal amount, + CardModel? cardSource, + CardPlay? cardPlay, + out IEnumerable modifiers) + { + decimal num = amount; + List abstractModelList = new List(); + + foreach (var item in combatState.IterateHookListeners()) + { + if (item is IVitalityAmountModifier mod) + { + var num2 = mod.ModifyVitalityAdditive(creature, num, cardSource, cardPlay); + num += num2; + if (num2 != 0M) + abstractModelList.Add(item); + } + } + + foreach (var item in combatState.IterateHookListeners()) + { + if (item is IVitalityAmountModifier mod) + { + var num2 = mod.ModifyVitalityMultiplicative(creature, num, cardSource, cardPlay); + num *= num2; + if (num2 != 0M) + abstractModelList.Add(item); + } + } + + modifiers = abstractModelList; + return Math.Max(0m, num); + } + + static async Task BeforeVitalityGained( + CombatState combatState, + Creature creature, + Decimal amount, + CardModel? cardSource) + { + foreach (var item in combatState.IterateHookListeners()) + { + if (item is IVitalityHooks mod) + { + await mod.BeforeVitalityGained(creature, amount, cardSource); + item.InvokeExecutionFinished(); + } + } + } + + static async Task AfterModifyingVitalityAmount( + CombatState combatState, + Decimal amount, + CardModel? cardSource, + CardPlay? cardPlay, + IEnumerable modifiers) + { + foreach (var item in combatState.IterateHookListeners()) + { + if (item is IVitalityHooks mod && modifiers.Contains(item)) + { + await mod.AfterModifyingVitalityAmount(amount, cardSource, cardPlay); + item.InvokeExecutionFinished(); + } + } + } + + static async Task AfterVitalityGained( + CombatState combatState, + Creature creature, + Decimal amount, + CardModel? cardSource) + { + foreach (var item in combatState.IterateHookListeners()) + { + if (item is IVitalityHooks mod) + { + await mod.AfterVitalityGained(creature, amount, cardSource); + item.InvokeExecutionFinished(); + } + } + } + + public class VitalityGainedEntry : CombatHistoryEntry + { + public int Amount { get; } + + public Creature Receiver => Actor; + + public CardPlay? CardPlay { get; } + + public override string Description + { + get => $"{GetId(Receiver)} gained {Amount} vitality"; + } + + public VitalityGainedEntry( + int amount, + CardPlay? cardPlay, + Creature receiver, + int roundNumber, + CombatSide currentSide, + CombatHistory history) + : base(receiver, roundNumber, currentSide, history) + { + Amount = amount; + CardPlay = cardPlay; + } + + public 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/Commands/VitalityCmd.cs.uid b/Commands/VitalityCmd.cs.uid new file mode 100644 index 00000000..ceb1b8c7 --- /dev/null +++ b/Commands/VitalityCmd.cs.uid @@ -0,0 +1 @@ +uid://dq3c3rm1cl23x diff --git a/Hooks/IVitalityAmountModifier.cs b/Hooks/IVitalityAmountModifier.cs new file mode 100644 index 00000000..106f416e --- /dev/null +++ b/Hooks/IVitalityAmountModifier.cs @@ -0,0 +1,27 @@ +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Models; + +namespace TestMod.TestModCode.Hooks; + +public interface IVitalityAmountModifier +{ + /// + /// Return the amount to add. + /// + /// + /// + /// + /// + /// + decimal ModifyVitalityAdditive(Creature creature, decimal amount, CardModel cardSource, CardPlay? cardPlay) => 0m; + /// + /// Return the amount to multiply by. + /// + /// + /// + /// + /// + /// + decimal ModifyVitalityMultiplicative(Creature creature, decimal amount, CardModel cardSource, CardPlay? cardPlay) => 1m; +} \ No newline at end of file diff --git a/Hooks/IVitalityAmountModifier.cs.uid b/Hooks/IVitalityAmountModifier.cs.uid new file mode 100644 index 00000000..e96c3160 --- /dev/null +++ b/Hooks/IVitalityAmountModifier.cs.uid @@ -0,0 +1 @@ +uid://bwf5fpvjk8j0x diff --git a/Hooks/IVitalityHooks.cs b/Hooks/IVitalityHooks.cs new file mode 100644 index 00000000..2f956876 --- /dev/null +++ b/Hooks/IVitalityHooks.cs @@ -0,0 +1,34 @@ +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Models; + +namespace TestMod.TestModCode.Hooks; + +public interface IVitalityHooks +{ + /// + /// Called before Vitality is gained. + /// + /// + /// + /// + /// + Task BeforeVitalityGained (Creature creature, decimal amount, CardModel cardSource) => Task.CompletedTask; + /// + /// Called after if Vitality was modified by Model, but before Vitality is gained. + /// + /// + /// + /// + /// + Task AfterModifyingVitalityAmount(decimal amount, CardModel cardSource, CardPlay? cardPlay) => Task.CompletedTask; + + /// + /// Called after Vitality is gained. + /// + /// + /// + /// + /// + Task AfterVitalityGained(Creature creature, decimal amount, CardModel cardSource) => Task.CompletedTask; +} \ No newline at end of file diff --git a/Patches/VitalityPatch.cs b/Patches/VitalityPatch.cs new file mode 100644 index 00000000..8aae1931 --- /dev/null +++ b/Patches/VitalityPatch.cs @@ -0,0 +1,382 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Hooks; +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.Multiplayer; +using MegaCrit.Sts2.Core.Runs; +using MegaCrit.Sts2.Core.Saves; +using MegaCrit.Sts2.Core.Settings; +using TestMod.TestModCode.Hooks; +using TestMod.TestModCode.Ui; + +namespace TestMod.TestModCode.Patches; + +public static class VitalityPatch +{ + private static readonly Color VitalityOutlineColor = new Color("FFC800"); + private static readonly Color VitalityTextOutlineColor = new Color("998000"); + + 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> VitalityChanged2 = new(() => null); // this exists only for CombatStateTracker. + public static readonly SpireField VitalityTween = new(() => null); + public static void SetVitality(Creature creature, int value) + { + if (value < 0) + throw new ArgumentException("Block must be positive", nameof (value)); + if (TemporaryHp.Get(creature) == value) + return; + int tempHp = TemporaryHp.Get(creature); + TemporaryHp.Set(creature, value); + Action? vitalityChanged = VitalityChanged.Get(creature); + vitalityChanged?.Invoke(tempHp, TemporaryHp.Get(creature), creature); + Action vitalityChanged2 = VitalityChanged2.Get(creature); + vitalityChanged?.Invoke(tempHp, TemporaryHp.Get(creature), creature); + } + + public static int GetVitality(Creature creature) + { + return TemporaryHp.Get(creature); + } + } + + [HarmonyPatch(typeof(Creature))] + [HarmonyPatch("LoseHpInternal")] + public class HpInterceptPatch + { + private static int temporaryHp; + + 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)); + MethodInfo unblockedOverride = AccessTools.Method(typeof(HpInterceptPatch), nameof(UnblockedDamageOverride)); + + 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) + ); + + codeMatcher.MatchStartForward( + new CodeMatch(OpCodes.Ldloc_1), + new CodeMatch(OpCodes.Ldarg_0), + new CodeMatch(OpCodes.Call, getCurrentHpInfo), + new CodeMatch(OpCodes.Sub) + ) + .ThrowIfInvalid("Couldn't find getCurrentHp method for TemporaryHpConfig") + .InsertAfterAndAdvance( + new CodeMatch(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Call, unblockedOverride) + ); + + return codeMatcher.InstructionEnumeration(); + } + + private static int TemporaryHpHandler(Creature c, int num) + { + int tempHp = temporaryHp = (int) VitalityField.GetVitality(c); + if (num >= tempHp) + { + num -= tempHp; + VitalityField.SetVitality(c, 0); + } + else + { + VitalityField.SetVitality(c, tempHp - num); + num = 0; + } + return num; + } + + private static int UnblockedDamageOverride(int unblockedDamage, Creature c) + { + if (TestModConfig.TriggerHpLoss) + { + temporaryHp -= (int) VitalityField.GetVitality(c); + return unblockedDamage + temporaryHp; + } + return unblockedDamage; + } + } + + [HarmonyPatch(typeof(NHealthBar))] + [HarmonyPatch("IsPoisonLethal")] + public class TemporaryHpPoisonPatch + { + static bool Postfix(bool __result, int poisonDamage, Creature ____creature) + { + if (!__result) + { + return __result; + } + return ____creature.CurrentHp + VitalityField.GetVitality(____creature) <= poisonDamage; + } + + } + + [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; + ____blockOutline.SelfModulate = VitalityOutlineColor; + } + } + + [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 mild 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 = GD.Load("res://images/atlases/ui_atlas.sprites/top_bar/top_bar_heart.tres"); + icon.SelfModulate = VitalityOutlineColor; + + var shaderCode = @" + shader_type canvas_item; + uniform vec4 tint_color : source_color = vec4(0.6, 0.6, 0, 1.0); + void fragment() { + vec4 tex = texture(TEXTURE, UV); + COLOR = vec4(tint_color.rgb, tex.a); + }"; + var shader = new Shader(); + shader.Code = shaderCode; + var material = new ShaderMaterial(); + material.Shader = shader; + material.SetShaderParameter("tint_color", VitalityOutlineColor); + icon.Material = material; + + 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); + } + } + 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/Patches/VitalityPatch.cs.uid b/Patches/VitalityPatch.cs.uid new file mode 100644 index 00000000..7e4052d8 --- /dev/null +++ b/Patches/VitalityPatch.cs.uid @@ -0,0 +1 @@ +uid://c3yt8v41pfnul From 142b470310f477c3469a81d433d6d6e0209d7232 Mon Sep 17 00:00:00 2001 From: SoytheProton Date: Wed, 15 Apr 2026 17:56:39 -0700 Subject: [PATCH 2/6] fixes --- .../localization/eng/static_hover_tips.json | 4 ++- Cards/Variables/VitalityVar.cs | 14 ++++++++++ Commands/VitalityCmd.cs | 11 +++----- Hooks/IVitalityAmountModifier.cs | 2 +- Hooks/IVitalityHooks.cs | 2 +- Patches/{ => Features}/VitalityPatch.cs | 26 ++++++++----------- Patches/{ => Features}/VitalityPatch.cs.uid | 0 7 files changed, 34 insertions(+), 25 deletions(-) create mode 100644 Cards/Variables/VitalityVar.cs rename Patches/{ => Features}/VitalityPatch.cs (93%) rename Patches/{ => Features}/VitalityPatch.cs.uid (100%) diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json index ed6c49da..da0cf952 100644 --- a/BaseLib/localization/eng/static_hover_tips.json +++ b/BaseLib/localization/eng/static_hover_tips.json @@ -4,5 +4,7 @@ "BASELIB-EXHAUSTIVE.title": "Exhaustive", "BASELIB-EXHAUSTIVE.description": "This card [gold]Exhausts[/gold] after {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |}{Exhaustive:plural:use|uses}.", "BASELIB-REFUND.title": "Refund", - "BASELIB-REFUND.description": "When energy is spent on this card, up to [blue]{Refund}[/blue] of that energy is refunded." + "BASELIB-REFUND.description": "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." } \ No newline at end of file diff --git a/Cards/Variables/VitalityVar.cs b/Cards/Variables/VitalityVar.cs new file mode 100644 index 00000000..06edd476 --- /dev/null +++ b/Cards/Variables/VitalityVar.cs @@ -0,0 +1,14 @@ +using BaseLib.Extensions; +using MegaCrit.Sts2.Core.Localization.DynamicVars; + +namespace BaseLib.Cards.Variables; + +public class VitalityVar : DynamicVar +{ + public const string Key = "Vitality"; + + public VitalityVar(decimal baseValue) : base(Key, baseValue) + { + this.WithTooltip(); + } +} \ No newline at end of file diff --git a/Commands/VitalityCmd.cs b/Commands/VitalityCmd.cs index ee62ed2f..a31a72e7 100644 --- a/Commands/VitalityCmd.cs +++ b/Commands/VitalityCmd.cs @@ -1,16 +1,13 @@ using MegaCrit.Sts2.Core.Combat; using MegaCrit.Sts2.Core.Combat.History; -using MegaCrit.Sts2.Core.Combat.History.Entries; using MegaCrit.Sts2.Core.Commands; using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Models; -using MegaCrit.Sts2.Core.Runs; -using MegaCrit.Sts2.Core.ValueProps; -using TestMod.TestModCode.Hooks; -using TestMod.TestModCode.Patches; +using BaseLib.Hooks; +using BaseLib.Patches; -namespace TestMod.TestModCode.Commands; +namespace BaseLib.Commands; public class VitalityCmd { @@ -130,7 +127,7 @@ static async Task AfterVitalityGained( } } - public class VitalityGainedEntry : CombatHistoryEntry + private class VitalityGainedEntry : CombatHistoryEntry { public int Amount { get; } diff --git a/Hooks/IVitalityAmountModifier.cs b/Hooks/IVitalityAmountModifier.cs index 106f416e..6ff46e46 100644 --- a/Hooks/IVitalityAmountModifier.cs +++ b/Hooks/IVitalityAmountModifier.cs @@ -2,7 +2,7 @@ using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Models; -namespace TestMod.TestModCode.Hooks; +namespace BaseLib.Hooks; public interface IVitalityAmountModifier { diff --git a/Hooks/IVitalityHooks.cs b/Hooks/IVitalityHooks.cs index 2f956876..69682bb2 100644 --- a/Hooks/IVitalityHooks.cs +++ b/Hooks/IVitalityHooks.cs @@ -2,7 +2,7 @@ using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Models; -namespace TestMod.TestModCode.Hooks; +namespace BaseLib.Hooks; public interface IVitalityHooks { diff --git a/Patches/VitalityPatch.cs b/Patches/Features/VitalityPatch.cs similarity index 93% rename from Patches/VitalityPatch.cs rename to Patches/Features/VitalityPatch.cs index 8aae1931..cd01c146 100644 --- a/Patches/VitalityPatch.cs +++ b/Patches/Features/VitalityPatch.cs @@ -6,21 +6,14 @@ using HarmonyLib; using MegaCrit.Sts2.addons.mega_text; using MegaCrit.Sts2.Core.Combat; -using MegaCrit.Sts2.Core.Commands; -using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Entities.Creatures; -using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.Helpers; -using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Nodes.Combat; using MegaCrit.Sts2.Core.Nodes.Multiplayer; -using MegaCrit.Sts2.Core.Runs; using MegaCrit.Sts2.Core.Saves; using MegaCrit.Sts2.Core.Settings; -using TestMod.TestModCode.Hooks; -using TestMod.TestModCode.Ui; -namespace TestMod.TestModCode.Patches; +namespace BaseLib.Patches.Features; public static class VitalityPatch { @@ -61,7 +54,7 @@ public static int GetVitality(Creature creature) [HarmonyPatch("LoseHpInternal")] public class HpInterceptPatch { - private static int temporaryHp; + // private static int temporaryHp; static IEnumerable Transpiler(IEnumerable instructions) { @@ -69,7 +62,7 @@ static IEnumerable Transpiler(IEnumerable inst MethodInfo getCurrentHpInfo = AccessTools.PropertyGetter(typeof(Creature), nameof(Creature.CurrentHp)); MethodInfo tempHp = AccessTools.Method(typeof(HpInterceptPatch), nameof(TemporaryHpHandler)); - MethodInfo unblockedOverride = AccessTools.Method(typeof(HpInterceptPatch), nameof(UnblockedDamageOverride)); + // MethodInfo unblockedOverride = AccessTools.Method(typeof(HpInterceptPatch), nameof(UnblockedDamageOverride)); codeMatcher.MatchStartForward( new CodeMatch(OpCodes.Ldarg_0), @@ -84,7 +77,7 @@ static IEnumerable Transpiler(IEnumerable inst new CodeInstruction(OpCodes.Stloc_2) ); - codeMatcher.MatchStartForward( + /*codeMatcher.MatchStartForward( new CodeMatch(OpCodes.Ldloc_1), new CodeMatch(OpCodes.Ldarg_0), new CodeMatch(OpCodes.Call, getCurrentHpInfo), @@ -94,14 +87,14 @@ static IEnumerable Transpiler(IEnumerable inst .InsertAfterAndAdvance( new CodeMatch(OpCodes.Ldarg_0), new CodeInstruction(OpCodes.Call, unblockedOverride) - ); + );*/ return codeMatcher.InstructionEnumeration(); } private static int TemporaryHpHandler(Creature c, int num) { - int tempHp = temporaryHp = (int) VitalityField.GetVitality(c); + int tempHp = (int) VitalityField.GetVitality(c); if (num >= tempHp) { num -= tempHp; @@ -115,6 +108,7 @@ private static int TemporaryHpHandler(Creature c, int num) return num; } + /* Code for making Vitality trigger HP Loss effects. private static int UnblockedDamageOverride(int unblockedDamage, Creature c) { if (TestModConfig.TriggerHpLoss) @@ -123,7 +117,7 @@ private static int UnblockedDamageOverride(int unblockedDamage, Creature c) return unblockedDamage + temporaryHp; } return unblockedDamage; - } + }*/ } [HarmonyPatch(typeof(NHealthBar))] @@ -174,7 +168,7 @@ public static void SelfModulateOutline(Creature ____creature, MegaLabel ____hpLa } } - // Code courtesy of CanYou with mild alterations. + // Code courtesy of CanYou with alterations. [HarmonyPatch] public static class VitalityHealthBarPatch { @@ -251,6 +245,8 @@ public static void SetUpVitalityOffset(NHealthBar __instance) __instance._blockContainer.Position.Y); } } + + // 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]; diff --git a/Patches/VitalityPatch.cs.uid b/Patches/Features/VitalityPatch.cs.uid similarity index 100% rename from Patches/VitalityPatch.cs.uid rename to Patches/Features/VitalityPatch.cs.uid From bfd4b55812f9b702d5497d3baeba71e85c38e461 Mon Sep 17 00:00:00 2001 From: SoytheProton Date: Wed, 15 Apr 2026 18:04:37 -0700 Subject: [PATCH 3/6] Delete Commands/VitalityCmd.cs.uid --- Commands/VitalityCmd.cs.uid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Commands/VitalityCmd.cs.uid diff --git a/Commands/VitalityCmd.cs.uid b/Commands/VitalityCmd.cs.uid deleted file mode 100644 index ceb1b8c7..00000000 --- a/Commands/VitalityCmd.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dq3c3rm1cl23x From 0bf68e3f8b257202a368d18007ec181342566e6a Mon Sep 17 00:00:00 2001 From: SoytheProton Date: Wed, 15 Apr 2026 18:05:11 -0700 Subject: [PATCH 4/6] Delete Patches/Features/VitalityPatch.cs.uid --- Patches/Features/VitalityPatch.cs.uid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Patches/Features/VitalityPatch.cs.uid diff --git a/Patches/Features/VitalityPatch.cs.uid b/Patches/Features/VitalityPatch.cs.uid deleted file mode 100644 index 7e4052d8..00000000 --- a/Patches/Features/VitalityPatch.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c3yt8v41pfnul From 7bf1789a93d3a35be150e405956ba2b2559b2d8f Mon Sep 17 00:00:00 2001 From: SoytheProton Date: Mon, 15 Jun 2026 14:30:43 -0700 Subject: [PATCH 5/6] Updated for New Version --- Commands/VitalityCmd.cs | 26 ++++++++++++++------------ Patches/Features/VitalityPatch.cs | 27 ++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/Commands/VitalityCmd.cs b/Commands/VitalityCmd.cs index a31a72e7..972514f2 100644 --- a/Commands/VitalityCmd.cs +++ b/Commands/VitalityCmd.cs @@ -1,11 +1,12 @@ -using MegaCrit.Sts2.Core.Combat; +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; using MegaCrit.Sts2.Core.Models; -using BaseLib.Hooks; -using BaseLib.Patches; namespace BaseLib.Commands; @@ -19,7 +20,7 @@ public static async Task GainVitality( { if (CombatManager.Instance.IsOverOrEnding) return 0M; - CombatState combatState = creature.CombatState; + ICombatState combatState = creature.CombatState; await BeforeVitalityGained(combatState, creature, amount, cardPlay?.Card); Decimal modifiedAmount = amount; IEnumerable modifiers; @@ -31,7 +32,7 @@ public static async Task GainVitality( 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(new VitalityGainedEntry((int)modifiedAmount, cardPlay, creature, combatState.RoundNumber, combatState.CurrentSide, CombatManager.Instance.History)); + 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 @@ -42,7 +43,7 @@ public static async Task GainVitality( } static decimal ModifyVitality( - CombatState combatState, + ICombatState combatState, Creature creature, Decimal amount, CardModel? cardSource, @@ -79,7 +80,7 @@ static decimal ModifyVitality( } static async Task BeforeVitalityGained( - CombatState combatState, + ICombatState combatState, Creature creature, Decimal amount, CardModel? cardSource) @@ -95,7 +96,7 @@ static async Task BeforeVitalityGained( } static async Task AfterModifyingVitalityAmount( - CombatState combatState, + ICombatState combatState, Decimal amount, CardModel? cardSource, CardPlay? cardPlay, @@ -112,7 +113,7 @@ static async Task AfterModifyingVitalityAmount( } static async Task AfterVitalityGained( - CombatState combatState, + ICombatState combatState, Creature creature, Decimal amount, CardModel? cardSource) @@ -146,14 +147,15 @@ public VitalityGainedEntry( Creature receiver, int roundNumber, CombatSide currentSide, - CombatHistory history) - : base(receiver, roundNumber, currentSide, history) + CombatHistory history, + IEnumerable players) + : base(receiver, roundNumber, currentSide, history, players) { Amount = amount; CardPlay = cardPlay; } - public static string GetId(Creature creature) + private static string GetId(Creature creature) { return !creature.IsPlayer ? creature.Monster.Id.Entry : creature.Player.Character.Id.Entry; } diff --git a/Patches/Features/VitalityPatch.cs b/Patches/Features/VitalityPatch.cs index cd01c146..04b9b5d4 100644 --- a/Patches/Features/VitalityPatch.cs +++ b/Patches/Features/VitalityPatch.cs @@ -7,7 +7,9 @@ using MegaCrit.Sts2.addons.mega_text; using MegaCrit.Sts2.Core.Combat; using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Hooks; using MegaCrit.Sts2.Core.Nodes.Combat; using MegaCrit.Sts2.Core.Nodes.Multiplayer; using MegaCrit.Sts2.Core.Saves; @@ -17,8 +19,9 @@ namespace BaseLib.Patches.Features; public static class VitalityPatch { - private static readonly Color VitalityOutlineColor = new Color("FFC800"); - private static readonly Color VitalityTextOutlineColor = new Color("998000"); + private static readonly Color VitalityHeartColor = new Color("FFC800"); + private static readonly Color VitalityOutlineColor = new Color((255f+80f)/255f,(220f+40f)/255f,(100)/255f); + private static readonly Color VitalityTextOutlineColor = new Color("505000"); public static class VitalityField { @@ -30,7 +33,7 @@ public static class VitalityField public static readonly SpireField?> VitalityChanged = new(() => null); public static readonly SpireField> VitalityChanged2 = new(() => null); // this exists only for CombatStateTracker. public static readonly SpireField VitalityTween = new(() => null); - public static void SetVitality(Creature creature, int value) + public static void SetVitality(Creature creature, int value) { if (value < 0) throw new ArgumentException("Block must be positive", nameof (value)); @@ -186,7 +189,7 @@ public static void CreateVitalityUi(NHealthBar __instance, Control ____blockCont // Swap block icon for heart, tinted yellow var icon = vitalityContainer.GetNode("BlockIcon"); icon.Texture = GD.Load("res://images/atlases/ui_atlas.sprites/top_bar/top_bar_heart.tres"); - icon.SelfModulate = VitalityOutlineColor; + icon.SelfModulate = VitalityHeartColor; var shaderCode = @" shader_type canvas_item; @@ -199,7 +202,7 @@ void fragment() { shader.Code = shaderCode; var material = new ShaderMaterial(); material.Shader = shader; - material.SetShaderParameter("tint_color", VitalityOutlineColor); + material.SetShaderParameter("tint_color", VitalityHeartColor); icon.Material = material; var label = vitalityContainer.GetNode("BlockLabel"); @@ -375,4 +378,18 @@ static void Postfix(Creature creature) } } } + + [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); + } + } + } + } \ No newline at end of file From 8125fe9a995dfbf34048bb5d53c52a515e3566a9 Mon Sep 17 00:00:00 2001 From: SoytheProton Date: Sun, 26 Jul 2026 17:24:43 -0700 Subject: [PATCH 6/6] Updated PR to have proper hooks with XML documentation, removing the HP Bar aspect, and more changes. Other adjustments include adding a ModifyMultiplicative to HookUtils.cs due to the fact that Modify alone didn't match the methods that basegame would use for multiplicative changes. Up to maintainers to determine if it's worth keeping, though I personally believe it is. --- BaseLib/images/ui/tempHP.png | Bin 0 -> 3964 bytes .../localization/eng/static_hover_tips.json | 4 +- Cards/Variables/VitalityVar.cs | 41 ++++- Commands/VitalityCmd.cs | 125 ++++---------- Extensions/DynamicVarSetExtensions.cs | 7 + Hooks/BaseLibHooks.cs | 105 ++++++++++++ Hooks/IModifyVitalityAmount.cs | 77 +++++++++ Hooks/IVitalityAmountModifier.cs | 27 ---- Hooks/IVitalityAmountModifier.cs.uid | 1 - Hooks/IVitalityHooks.cs | 84 ++++++---- Patches/Features/VitalityPatch.cs | 152 ++++++++---------- Utils/HookUtils.cs | 58 +++++++ 12 files changed, 436 insertions(+), 245 deletions(-) create mode 100644 BaseLib/images/ui/tempHP.png create mode 100644 Hooks/IModifyVitalityAmount.cs delete mode 100644 Hooks/IVitalityAmountModifier.cs delete mode 100644 Hooks/IVitalityAmountModifier.cs.uid diff --git a/BaseLib/images/ui/tempHP.png b/BaseLib/images/ui/tempHP.png new file mode 100644 index 0000000000000000000000000000000000000000..d5180b5be0b1bfbaed54a0bcb497c91e5df5d769 GIT binary patch literal 3964 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 JG{ej_?td*Za1?[blue]{Exhaustive}[/blue] |}{Exhaustive:plural:use|uses}.", "BASELIB-REFUND.title": "Refund", - "BASELIB-VITALITY.title": "Vitality", - "BASELIB-VITALITY.description": "Until the end of combat, prevents HP loss." "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 index 06edd476..cdb733c8 100644 --- a/Cards/Variables/VitalityVar.cs +++ b/Cards/Variables/VitalityVar.cs @@ -1,14 +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 { - public const string Key = "Vitality"; - - public VitalityVar(decimal baseValue) : base(Key, baseValue) + /// + /// 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 index 972514f2..aa76feb3 100644 --- a/Commands/VitalityCmd.cs +++ b/Commands/VitalityCmd.cs @@ -6,27 +6,38 @@ using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Entities.Creatures; using MegaCrit.Sts2.Core.Entities.Players; -using MegaCrit.Sts2.Core.Models; namespace BaseLib.Commands; +/// +/// Command utility responsible for executing the vitality mechanic process, including +/// hook modification, finding current values, and combatHistory entries. +/// public class VitalityCmd { - public static async Task GainVitality( + /// 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, + decimal amount, CardPlay? cardPlay, bool fast = false) { if (CombatManager.Instance.IsOverOrEnding) return 0M; - ICombatState combatState = creature.CombatState; - await BeforeVitalityGained(combatState, creature, amount, cardPlay?.Card); - Decimal modifiedAmount = amount; - IEnumerable modifiers; - modifiedAmount = ModifyVitality(combatState, creature, modifiedAmount, cardPlay.Card, cardPlay, out modifiers); + 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 AfterModifyingVitalityAmount(combatState, modifiedAmount, cardPlay?.Card, cardPlay, modifiers); + await BaseLibHooks.AfterModifyingVitalityAmount(combatState, modifiedAmount, cardPlay, modifiers); if (modifiedAmount > 0M) { SfxCmd.Play("event:/sfx/heal"); @@ -38,94 +49,23 @@ public static async Task GainVitality( else await Cmd.CustomScaledWait(0.1f, 0.25f); } - await AfterVitalityGained(combatState, creature, modifiedAmount, cardPlay?.Card); + await BaseLibHooks.AfterVitalityGained(combatState, creature, modifiedAmount, cardPlay?.Card); return modifiedAmount; } - static decimal ModifyVitality( - ICombatState combatState, - Creature creature, - Decimal amount, - CardModel? cardSource, - CardPlay? cardPlay, - out IEnumerable modifiers) + /// 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) { - decimal num = amount; - List abstractModelList = new List(); - - foreach (var item in combatState.IterateHookListeners()) - { - if (item is IVitalityAmountModifier mod) - { - var num2 = mod.ModifyVitalityAdditive(creature, num, cardSource, cardPlay); - num += num2; - if (num2 != 0M) - abstractModelList.Add(item); - } - } - - foreach (var item in combatState.IterateHookListeners()) - { - if (item is IVitalityAmountModifier mod) - { - var num2 = mod.ModifyVitalityMultiplicative(creature, num, cardSource, cardPlay); - num *= num2; - if (num2 != 0M) - abstractModelList.Add(item); - } - } - - modifiers = abstractModelList; - return Math.Max(0m, num); + return VitalityPatch.VitalityField.GetVitality(creature); } - static async Task BeforeVitalityGained( - ICombatState combatState, - Creature creature, - Decimal amount, - CardModel? cardSource) + /// Removes all vitality a specific creature has. + /// Creature you're trying to remove the vitality from. + public static void RemoveAll(Creature creature) { - foreach (var item in combatState.IterateHookListeners()) - { - if (item is IVitalityHooks mod) - { - await mod.BeforeVitalityGained(creature, amount, cardSource); - item.InvokeExecutionFinished(); - } - } - } - - static async Task AfterModifyingVitalityAmount( - ICombatState combatState, - Decimal amount, - CardModel? cardSource, - CardPlay? cardPlay, - IEnumerable modifiers) - { - foreach (var item in combatState.IterateHookListeners()) - { - if (item is IVitalityHooks mod && modifiers.Contains(item)) - { - await mod.AfterModifyingVitalityAmount(amount, cardSource, cardPlay); - item.InvokeExecutionFinished(); - } - } - } - - static async Task AfterVitalityGained( - ICombatState combatState, - Creature creature, - Decimal amount, - CardModel? cardSource) - { - foreach (var item in combatState.IterateHookListeners()) - { - if (item is IVitalityHooks mod) - { - await mod.AfterVitalityGained(creature, amount, cardSource); - item.InvokeExecutionFinished(); - } - } + VitalityPatch.VitalityField.SetVitality(creature, 0); } private class VitalityGainedEntry : CombatHistoryEntry @@ -136,10 +76,7 @@ private class VitalityGainedEntry : CombatHistoryEntry public CardPlay? CardPlay { get; } - public override string Description - { - get => $"{GetId(Receiver)} gained {Amount} vitality"; - } + public override string Description => $"{GetId(Receiver)} gained {Amount} vitality"; public VitalityGainedEntry( int amount, 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/IVitalityAmountModifier.cs b/Hooks/IVitalityAmountModifier.cs deleted file mode 100644 index 6ff46e46..00000000 --- a/Hooks/IVitalityAmountModifier.cs +++ /dev/null @@ -1,27 +0,0 @@ -using MegaCrit.Sts2.Core.Entities.Cards; -using MegaCrit.Sts2.Core.Entities.Creatures; -using MegaCrit.Sts2.Core.Models; - -namespace BaseLib.Hooks; - -public interface IVitalityAmountModifier -{ - /// - /// Return the amount to add. - /// - /// - /// - /// - /// - /// - decimal ModifyVitalityAdditive(Creature creature, decimal amount, CardModel cardSource, CardPlay? cardPlay) => 0m; - /// - /// Return the amount to multiply by. - /// - /// - /// - /// - /// - /// - decimal ModifyVitalityMultiplicative(Creature creature, decimal amount, CardModel cardSource, CardPlay? cardPlay) => 1m; -} \ No newline at end of file diff --git a/Hooks/IVitalityAmountModifier.cs.uid b/Hooks/IVitalityAmountModifier.cs.uid deleted file mode 100644 index e96c3160..00000000 --- a/Hooks/IVitalityAmountModifier.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bwf5fpvjk8j0x diff --git a/Hooks/IVitalityHooks.cs b/Hooks/IVitalityHooks.cs index 69682bb2..21a80f14 100644 --- a/Hooks/IVitalityHooks.cs +++ b/Hooks/IVitalityHooks.cs @@ -1,34 +1,52 @@ -using MegaCrit.Sts2.Core.Entities.Cards; -using MegaCrit.Sts2.Core.Entities.Creatures; -using MegaCrit.Sts2.Core.Models; - -namespace BaseLib.Hooks; - -public interface IVitalityHooks -{ - /// - /// Called before Vitality is gained. - /// - /// - /// - /// - /// - Task BeforeVitalityGained (Creature creature, decimal amount, CardModel cardSource) => Task.CompletedTask; - /// - /// Called after if Vitality was modified by Model, but before Vitality is gained. - /// - /// - /// - /// - /// - Task AfterModifyingVitalityAmount(decimal amount, CardModel cardSource, CardPlay? cardPlay) => Task.CompletedTask; - - /// - /// Called after Vitality is gained. - /// - /// - /// - /// - /// - Task AfterVitalityGained(Creature creature, decimal amount, CardModel cardSource) => Task.CompletedTask; +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 index 04b9b5d4..c532cd70 100644 --- a/Patches/Features/VitalityPatch.cs +++ b/Patches/Features/VitalityPatch.cs @@ -1,17 +1,18 @@ using System.Reflection; using System.Reflection.Emit; -using BaseLib.Hooks; 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.Entities.Players; 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; @@ -19,9 +20,10 @@ namespace BaseLib.Patches.Features; public static class VitalityPatch { - private static readonly Color VitalityHeartColor = new Color("FFC800"); - private static readonly Color VitalityOutlineColor = new Color((255f+80f)/255f,(220f+40f)/255f,(100)/255f); - private static readonly Color VitalityTextOutlineColor = new Color("505000"); + + 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 { @@ -31,41 +33,34 @@ public static class VitalityField private static readonly SpireField TemporaryHp = new(() => 0); public static readonly SpireField?> VitalityChanged = new(() => null); - public static readonly SpireField> VitalityChanged2 = new(() => null); // this exists only for CombatStateTracker. public static readonly SpireField VitalityTween = new(() => null); - public static void SetVitality(Creature creature, int value) + internal static void SetVitality(Creature creature, int value) { if (value < 0) - throw new ArgumentException("Block must be positive", nameof (value)); + throw new ArgumentException("Vitality must be positive ", nameof (value)); if (TemporaryHp.Get(creature) == value) return; - int tempHp = TemporaryHp.Get(creature); + var tempHp = TemporaryHp.Get(creature); TemporaryHp.Set(creature, value); - Action? vitalityChanged = VitalityChanged.Get(creature); - vitalityChanged?.Invoke(tempHp, TemporaryHp.Get(creature), creature); - Action vitalityChanged2 = VitalityChanged2.Get(creature); + var vitalityChanged = VitalityChanged.Get(creature); vitalityChanged?.Invoke(tempHp, TemporaryHp.Get(creature), creature); } - public static int GetVitality(Creature creature) + internal static int GetVitality(Creature creature) { return TemporaryHp.Get(creature); } } - [HarmonyPatch(typeof(Creature))] - [HarmonyPatch("LoseHpInternal")] + [HarmonyPatch(typeof(Creature), nameof(Creature.LoseHpInternal))] public class HpInterceptPatch { - // private static int temporaryHp; - 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)); - // MethodInfo unblockedOverride = AccessTools.Method(typeof(HpInterceptPatch), nameof(UnblockedDamageOverride)); codeMatcher.MatchStartForward( new CodeMatch(OpCodes.Ldarg_0), @@ -79,25 +74,13 @@ static IEnumerable Transpiler(IEnumerable inst new CodeInstruction(OpCodes.Call, tempHp), new CodeInstruction(OpCodes.Stloc_2) ); - - /*codeMatcher.MatchStartForward( - new CodeMatch(OpCodes.Ldloc_1), - new CodeMatch(OpCodes.Ldarg_0), - new CodeMatch(OpCodes.Call, getCurrentHpInfo), - new CodeMatch(OpCodes.Sub) - ) - .ThrowIfInvalid("Couldn't find getCurrentHp method for TemporaryHpConfig") - .InsertAfterAndAdvance( - new CodeMatch(OpCodes.Ldarg_0), - new CodeInstruction(OpCodes.Call, unblockedOverride) - );*/ - + return codeMatcher.InstructionEnumeration(); } private static int TemporaryHpHandler(Creature c, int num) { - int tempHp = (int) VitalityField.GetVitality(c); + var tempHp = VitalityField.GetVitality(c); if (num >= tempHp) { num -= tempHp; @@ -108,51 +91,75 @@ private static int TemporaryHpHandler(Creature c, int num) VitalityField.SetVitality(c, tempHp - num); num = 0; } + + var absorbed = tempHp - VitalityField.GetVitality(c); + if(absorbed > 0) PlayAbsorbFx(c, absorbed); + return num; } - - /* Code for making Vitality trigger HP Loss effects. - private static int UnblockedDamageOverride(int unblockedDamage, Creature c) - { - if (TestModConfig.TriggerHpLoss) - { - temporaryHp -= (int) VitalityField.GetVitality(c); - return unblockedDamage + temporaryHp; - } - return unblockedDamage; - }*/ } - [HarmonyPatch(typeof(NHealthBar))] - [HarmonyPatch("IsPoisonLethal")] - public class TemporaryHpPoisonPatch - { - static bool Postfix(bool __result, int poisonDamage, Creature ____creature) + [HarmonyPatch(typeof(Hook))] + [HarmonyPatch(nameof(Hook.AfterCombatEnd))] + public class CombatEndPatch + { + static void Postfix(ICombatState? combatState) { - if (!__result) + foreach (Creature c in combatState?.Creatures) { - return __result; + VitalityField.SetVitality(c, 0); } - return ____creature.CurrentHp + VitalityField.GetVitality(____creature) <= poisonDamage; } - + } + + // 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) + if (____creature.Block > 0 || VitalityField.GetVitality(____creature) <= 0) { ____blockOutline.SelfModulate = Colors.White; return; } ____blockOutline.Visible = true; - ____blockOutline.SelfModulate = VitalityOutlineColor; + 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; } } @@ -188,23 +195,9 @@ public static void CreateVitalityUi(NHealthBar __instance, Control ____blockCont // Swap block icon for heart, tinted yellow var icon = vitalityContainer.GetNode("BlockIcon"); - icon.Texture = GD.Load("res://images/atlases/ui_atlas.sprites/top_bar/top_bar_heart.tres"); + icon.Texture = PreloadManager.Cache.GetTexture2D("BaseLib/images/ui/tempHP.png"); icon.SelfModulate = VitalityHeartColor; - var shaderCode = @" - shader_type canvas_item; - uniform vec4 tint_color : source_color = vec4(0.6, 0.6, 0, 1.0); - void fragment() { - vec4 tex = texture(TEXTURE, UV); - COLOR = vec4(tint_color.rgb, tex.a); - }"; - var shader = new Shader(); - shader.Code = shaderCode; - var material = new ShaderMaterial(); - material.Shader = shader; - material.SetShaderParameter("tint_color", VitalityHeartColor); - icon.Material = material; - var label = vitalityContainer.GetNode("BlockLabel"); label.AddThemeColorOverride(ThemeConstants.Label.FontOutlineColor, VitalityTextOutlineColor); @@ -249,6 +242,8 @@ public static void SetUpVitalityOffset(NHealthBar __instance) } } + /* 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]; @@ -267,7 +262,8 @@ public IEnumerable GetHealthBarForecastSegments(Health } return list; } - } + } */ + public static void AnimateInVitality(int oldVitality, int vitalityGain, Creature creature) { AnimateInVitality(oldVitality, vitalityGain, VitalityHealthBarPatch.CreatureHealthBar[creature]); @@ -378,18 +374,4 @@ static void Postfix(Creature creature) } } } - - [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); - } - } - } - } \ 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;