From 66d8bc18bc874a0ba09e9fed93f730bb5101de2d Mon Sep 17 00:00:00 2001 From: snowlie <1939323749@qq.com> Date: Fri, 10 Jul 2026 17:43:36 +0800 Subject: [PATCH] Add temporary HP support --- BaseLib/images/ui/temp_hp.png | Bin 0 -> 3964 bytes BaseLib/localization/deu/card_keywords.json | 4 +- BaseLib/localization/deu/powers.json | 5 +- .../localization/deu/static_hover_tips.json | 4 +- BaseLib/localization/eng/card_keywords.json | 4 +- BaseLib/localization/eng/powers.json | 5 +- .../localization/eng/static_hover_tips.json | 4 +- BaseLib/localization/rus/card_keywords.json | 4 + BaseLib/localization/rus/powers.json | 5 +- .../localization/rus/static_hover_tips.json | 4 +- BaseLib/localization/zhs/card_keywords.json | 4 +- BaseLib/localization/zhs/powers.json | 5 +- .../localization/zhs/static_hover_tips.json | 4 +- Cards/BaseLibKeywords.cs | 6 + Commands/TempHpCmd.cs | 119 +++++++++++ ConsoleCommands/TempHpConsoleCmd.cs | 67 ++++++ Extensions/CreatureTempHpExtensions.cs | 29 +++ Hooks/ITempHpLossModifier.cs | 28 +++ Patches/UI/TempHpDisplayPatch.cs | 195 ++++++++++++++++++ Powers/TempHpPower.cs | 145 +++++++++++++ 20 files changed, 630 insertions(+), 11 deletions(-) create mode 100644 BaseLib/images/ui/temp_hp.png create mode 100644 BaseLib/localization/rus/card_keywords.json create mode 100644 Commands/TempHpCmd.cs create mode 100644 ConsoleCommands/TempHpConsoleCmd.cs create mode 100644 Extensions/CreatureTempHpExtensions.cs create mode 100644 Hooks/ITempHpLossModifier.cs create mode 100644 Patches/UI/TempHpDisplayPatch.cs create mode 100644 Powers/TempHpPower.cs diff --git a/BaseLib/images/ui/temp_hp.png b/BaseLib/images/ui/temp_hp.png new file mode 100644 index 0000000000000000000000000000000000000000..d5180b5be0b1bfbaed54a0bcb497c91e5df5d769 GIT binary patch literal 3964 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 JG{ej_?td*Za0?\\nHält [blue]{Repeat:diff()}[/blue] {Repeat:plural:weiteren Zug|weitere Züge} an.|}", "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "Verringert einen [gold]Effekt[/gold] für eine gewisse Zeit.", - "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Verliere [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] bis zum Ende {UntilEndOfOtherSideTurn:cond:dieses Zuges|des gegnerischen Zuges}.{Repeat:cond:>0?\\nHält [blue]{Repeat:diff()}[/blue] {Repeat:plural:weiteren Zug|weitere Züge} an.|}" + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Verliere [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] bis zum Ende {UntilEndOfOtherSideTurn:cond:dieses Zuges|des gegnerischen Zuges}.{Repeat:cond:>0?\\nHält [blue]{Repeat:diff()}[/blue] {Repeat:plural:weiteren Zug|weitere Züge} an.|}", + "BASELIB-TEMP_HP_POWER.title": "Temporäre LP", + "BASELIB-TEMP_HP_POWER.description": "Absorbiert Schaden, bevor LP verloren gehen – auch Schaden, der [gold]Block[/gold] ignoriert. Verschwindet am Ende des Kampfes.", + "BASELIB-TEMP_HP_POWER.smartDescription": "Absorbiert Schaden, bevor LP verloren gehen – auch Schaden, der [gold]Block[/gold] ignoriert. Verschwindet am Ende des Kampfes." } \ No newline at end of file diff --git a/BaseLib/localization/deu/static_hover_tips.json b/BaseLib/localization/deu/static_hover_tips.json index afc1ee47..65c47225 100644 --- a/BaseLib/localization/deu/static_hover_tips.json +++ b/BaseLib/localization/deu/static_hover_tips.json @@ -4,5 +4,7 @@ "BASELIB-EXHAUSTIVE.title": "Erschöpfend", "BASELIB-EXHAUSTIVE.description": "Diese Karte wird nach {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |einer }{Exhaustive:plural:Verwendung|Verwendungen} [gold]Erschöpft[/gold].", "BASELIB-REFUND.title": "Erstattung", - "BASELIB-REFUND.description": "Wird Energie für diese Karte ausgegeben, werden bis zu [blue]{Refund}[/blue] dieser Energie zurückerstattet." + "BASELIB-REFUND.description": "Wird Energie für diese Karte ausgegeben, werden bis zu [blue]{Refund}[/blue] dieser Energie zurückerstattet.", + "BASELIB-TEMP_HP.title": "Temporäre LP", + "BASELIB-TEMP_HP.description": "Absorbiert Schaden, bevor LP verloren gehen – auch Schaden, der [gold]Block[/gold] ignoriert. Verschwindet am Ende des Kampfes." } \ No newline at end of file diff --git a/BaseLib/localization/eng/card_keywords.json b/BaseLib/localization/eng/card_keywords.json index edf6c2d4..0e70eb7a 100644 --- a/BaseLib/localization/eng/card_keywords.json +++ b/BaseLib/localization/eng/card_keywords.json @@ -1,4 +1,6 @@ { "BASELIB-PURGE.title": "Purge", - "BASELIB-PURGE.description": "Removed from combat and your [gold]Deck[/gold] permanently." + "BASELIB-PURGE.description": "Removed from combat and your [gold]Deck[/gold] permanently.", + "BASELIB-TEMP_HP.title": "Temporary HP", + "BASELIB-TEMP_HP.description": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat." } diff --git a/BaseLib/localization/eng/powers.json b/BaseLib/localization/eng/powers.json index a6f43505..c101b12b 100644 --- a/BaseLib/localization/eng/powers.json +++ b/BaseLib/localization/eng/powers.json @@ -5,5 +5,8 @@ "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "Increase a [gold]Power[/gold] for some time.", "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "Gain [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] until the end of your {UntilEndOfOtherSideTurn:cond:|enemies }turn.{Repeat:cond:>0?\\nLasts for [blue]{Repeat:diff()}[/blue] additional {Repeat:plural:turn|turns}.|}", "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "Decrease a [gold]Power[/gold] for some time.", - "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Lose [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] until the end of your {UntilEndOfOtherSideTurn:cond:|enemies }turn.{Repeat:cond:>0?\\nLasts for [blue]{Repeat:diff()}[/blue] additional {Repeat:plural:turn|turns}.|}" + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Lose [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] until the end of your {UntilEndOfOtherSideTurn:cond:|enemies }turn.{Repeat:cond:>0?\\nLasts for [blue]{Repeat:diff()}[/blue] additional {Repeat:plural:turn|turns}.|}", + "BASELIB-TEMP_HP_POWER.title": "Temporary HP", + "BASELIB-TEMP_HP_POWER.description": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat.", + "BASELIB-TEMP_HP_POWER.smartDescription": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat." } diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json index ed6c49da..c0fc50cf 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-TEMP_HP.title": "Temporary HP", + "BASELIB-TEMP_HP.description": "Absorbs damage before HP is lost, even damage that ignores [gold]Block[/gold]. Disappears at the end of combat." } \ No newline at end of file diff --git a/BaseLib/localization/rus/card_keywords.json b/BaseLib/localization/rus/card_keywords.json new file mode 100644 index 00000000..72e07d27 --- /dev/null +++ b/BaseLib/localization/rus/card_keywords.json @@ -0,0 +1,4 @@ +{ + "BASELIB-TEMP_HP.title": "Временное здоровье", + "BASELIB-TEMP_HP.description": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя." +} diff --git a/BaseLib/localization/rus/powers.json b/BaseLib/localization/rus/powers.json index 19792e5a..9c04af6f 100644 --- a/BaseLib/localization/rus/powers.json +++ b/BaseLib/localization/rus/powers.json @@ -5,5 +5,8 @@ "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "Временно усиливает эффект [gold]таланта[/gold].", "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "Дает [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] до конца хода{UntilEndOfOtherSideTurn:cond:| врага}.{Repeat:cond:>0?\\nДействует еще [blue]{Repeat:diff()}[/blue] доп. {Repeat:plural(ru):ход|хода|ходов}.|}", "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "Временно ослабляет эффект [gold]таланта[/gold].", - "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Вы теряете [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] до конца хода {UntilEndOfOtherSideTurn:cond:| врага}.{Repeat:cond:>0?\\nДействует еще [blue]{Repeat:diff()}[/blue] доп. {Repeat:plural(ru):ход|хода|ходов}.|}" + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "Вы теряете [blue]{Amount:abs()}[/blue] [gold]{TemporaryPowerTitle}[/gold] до конца хода {UntilEndOfOtherSideTurn:cond:| врага}.{Repeat:cond:>0?\\nДействует еще [blue]{Repeat:diff()}[/blue] доп. {Repeat:plural(ru):ход|хода|ходов}.|}", + "BASELIB-TEMP_HP_POWER.title": "Временное здоровье", + "BASELIB-TEMP_HP_POWER.description": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя.", + "BASELIB-TEMP_HP_POWER.smartDescription": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя." } diff --git a/BaseLib/localization/rus/static_hover_tips.json b/BaseLib/localization/rus/static_hover_tips.json index 12f0473c..0a1c2a45 100644 --- a/BaseLib/localization/rus/static_hover_tips.json +++ b/BaseLib/localization/rus/static_hover_tips.json @@ -4,5 +4,7 @@ "BASELIB-EXHAUSTIVE.title": "Угасание", "BASELIB-EXHAUSTIVE.description": "Эта карта [gold]сжигается[/gold] после того, как будет разыграна еще {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |}{Exhaustive:plural(ru):раз|раза|раз}.", "BASELIB-REFUND.title": "Возврат", - "BASELIB-REFUND.description": "Когда вы тратите энергию при разыгрывании этой карты, возвращает до [blue]{Refund}[/blue] {energyPrefix:energyIcons(1)} назад." + "BASELIB-REFUND.description": "Когда вы тратите энергию при разыгрывании этой карты, возвращает до [blue]{Refund}[/blue] {energyPrefix:energyIcons(1)} назад.", + "BASELIB-TEMP_HP.title": "Временное здоровье", + "BASELIB-TEMP_HP.description": "Поглощает урон до потери здоровья, даже урон, игнорирующий [gold]блок[/gold]. Исчезает в конце боя." } \ No newline at end of file diff --git a/BaseLib/localization/zhs/card_keywords.json b/BaseLib/localization/zhs/card_keywords.json index 53893dfd..ee8e0710 100644 --- a/BaseLib/localization/zhs/card_keywords.json +++ b/BaseLib/localization/zhs/card_keywords.json @@ -1,4 +1,6 @@ { "BASELIB-PURGE.title": "即逝", - "BASELIB-PURGE.description": "从战斗和你的[gold]牌组[/gold]中永久移除。" + "BASELIB-PURGE.description": "从战斗和你的[gold]牌组[/gold]中永久移除。", + "BASELIB-TEMP_HP.title": "临时生命", + "BASELIB-TEMP_HP.description": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。" } diff --git a/BaseLib/localization/zhs/powers.json b/BaseLib/localization/zhs/powers.json index 61aef702..fc4ae9bd 100644 --- a/BaseLib/localization/zhs/powers.json +++ b/BaseLib/localization/zhs/powers.json @@ -5,5 +5,8 @@ "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "在一段时间内提升[gold]能力[/gold]。", "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "获得[blue]{Amount:abs()}[/blue]层[gold]{TemporaryPowerTitle}[/gold],持续到你的{UntilEndOfOtherSideTurn:cond:|敌人}回合结束。{Repeat:cond:>0?\\n额外持续[blue]{Repeat:diff()}[/blue]个回合。|}", "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.description": "在一段时间内降低[gold]能力[/gold]。", - "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "失去[blue]{Amount:abs()}[/blue]层[gold]{TemporaryPowerTitle}[/gold],持续到你的{UntilEndOfOtherSideTurn:cond:|敌人}回合结束。{Repeat:cond:>0?\\n额外持续[blue]{Repeat:diff()}[/blue]个回合。|}" + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.DOWN.smartDescription": "失去[blue]{Amount:abs()}[/blue]层[gold]{TemporaryPowerTitle}[/gold],持续到你的{UntilEndOfOtherSideTurn:cond:|敌人}回合结束。{Repeat:cond:>0?\\n额外持续[blue]{Repeat:diff()}[/blue]个回合。|}", + "BASELIB-TEMP_HP_POWER.title": "临时生命", + "BASELIB-TEMP_HP_POWER.description": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。", + "BASELIB-TEMP_HP_POWER.smartDescription": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。" } diff --git a/BaseLib/localization/zhs/static_hover_tips.json b/BaseLib/localization/zhs/static_hover_tips.json index ec3535bb..8506cb4a 100644 --- a/BaseLib/localization/zhs/static_hover_tips.json +++ b/BaseLib/localization/zhs/static_hover_tips.json @@ -4,5 +4,7 @@ "BASELIB-EXHAUSTIVE.title": "耐久", "BASELIB-EXHAUSTIVE.description": "这张牌在被使用 {Exhaustive:cond:>1?[blue]{Exhaustive}[/blue] |}次后会[gold]消耗[/gold]。", "BASELIB-REFUND.title": "回能", - "BASELIB-REFUND.description": "当此牌花费能量时,返还最多 [blue]{Refund}[/blue] 点能量。" + "BASELIB-REFUND.description": "当此牌花费能量时,返还最多 [blue]{Refund}[/blue] 点能量。", + "BASELIB-TEMP_HP.title": "临时生命", + "BASELIB-TEMP_HP.description": "在损失生命值之前吸收伤害,即使是无视[gold]格挡[/gold]的伤害。战斗结束时消失。" } diff --git a/Cards/BaseLibKeywords.cs b/Cards/BaseLibKeywords.cs index aed332c9..efbec7a7 100644 --- a/Cards/BaseLibKeywords.cs +++ b/Cards/BaseLibKeywords.cs @@ -10,4 +10,10 @@ public class BaseLibKeywords /// A card that removes itself from the deck when played. /// [CustomEnum] [KeywordProperties(AutoKeywordPosition.After)] public static CardKeyword Purge; + + /// See TempHpPower and TempHpCmd + /// + /// Temporary HP absorbs damage before HP and disappears at the end of combat. + /// + [CustomEnum("Temp_HP")] [KeywordProperties(AutoKeywordPosition.After)] public static CardKeyword TempHp; } \ No newline at end of file diff --git a/Commands/TempHpCmd.cs b/Commands/TempHpCmd.cs new file mode 100644 index 00000000..2e0f59a7 --- /dev/null +++ b/Commands/TempHpCmd.cs @@ -0,0 +1,119 @@ +using BaseLib.Patches.Content; +using BaseLib.Powers; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes; +using MegaCrit.Sts2.Core.Nodes.Vfx; +using MegaCrit.Sts2.Core.ValueProps; +using MegaCrit.Sts2.addons.mega_text; + +namespace BaseLib.Commands; + +/// +/// Public API for the Temporary HP mechanic (port of StSLib's Temporary HP). +/// Temporary HP absorbs any HP loss that goes through the damage pipeline (attacks, Poison, self-damage, ...). +/// Order: Block → Osty redirect → Temporary HP → late-pass buffs (ModifyHpLostAfterOstyLate, e.g. +/// Buffer-style prevention) → real HP. Disappears at the end of combat. +/// See for the underlying hidden power and +/// for the per-hit modification hook. +/// +public static class TempHpCmd +{ + /// + /// Reserved damage-props bit: damage carrying this flag bypasses Temporary HP entirely + /// (the StSLib "ignoresTempHP" equivalent). Vanilla only uses bits 1-4; BaseLib reserves bit 28. + /// + public const ValueProp IgnoresTempHp = (ValueProp)(1 << 28); + + /// Hover tip shown when a creature has Temporary HP. Usable on cards via WithTip(TempHpCmd.TempHp). + [CustomEnum] public static StaticHoverTip TempHp; + + /// Fired after a creature gains Temporary HP: (creature, amount gained, new total). + public static event Action? Gained; + + /// + /// Fired after a creature loses Temporary HP: (creature, amount lost, new total, damage that caused it). + /// The DamageResult is null for non-damage losses (RemoveAll, foreign PowerCmd decrements, death flush). + /// + public static event Action? Lost; + + /// + /// Grant Temporary HP to a creature. Stacks with existing Temporary HP. + /// Multiplayer-safe when called from synchronized contexts (card OnPlay, hooks, networked console commands), + /// exactly like any power application. + /// + /// The creature's new Temporary HP total. + public static async Task Add(PlayerChoiceContext choiceContext, Creature target, int amount, + Creature? applier = null, CardModel? cardSource = null, bool showVfx = true) + { + if (amount <= 0) + return Get(target); + var power = await PowerCmd.Apply(choiceContext, target, amount, applier, cardSource, silent: true); + if (power != null && showVfx) + PlayGainFx(target); + return power?.Amount ?? Get(target); + } + + /// Remove all Temporary HP from a creature (the StSLib RemoveAllTemporaryHPAction equivalent). + public static async Task RemoveAll(Creature target) + { + var power = target.GetPower(); + if (power == null) + return; + var had = power.Amount; + await PowerCmd.Remove(power); + if (had > 0) + InvokeLost(target, had, 0, null); + } + + /// The creature's current Temporary HP (0 if none). + public static int Get(Creature target) + { + return target.GetPower()?.Amount ?? 0; + } + + internal static void InvokeGained(Creature creature, int gained, int total) + { + Gained?.Invoke(creature, gained, total); + } + + internal static void InvokeLost(Creature creature, int lost, int total, DamageResult? source) + { + Lost?.Invoke(creature, lost, total, source); + } + + private static void PlayGainFx(Creature target) + { + if (!CombatManager.Instance.IsInProgress) + return; + SfxCmd.Play("event:/sfx/heal"); + VfxCmd.PlayOnCreatureCenter(target, "vfx/vfx_cross_heal"); + } + + /// + /// 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); + } +} diff --git a/ConsoleCommands/TempHpConsoleCmd.cs b/ConsoleCommands/TempHpConsoleCmd.cs new file mode 100644 index 00000000..ee2196bf --- /dev/null +++ b/ConsoleCommands/TempHpConsoleCmd.cs @@ -0,0 +1,67 @@ +using BaseLib.Commands; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.DevConsole; +using MegaCrit.Sts2.Core.DevConsole.ConsoleCommands; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Runs; + +namespace BaseLib.ConsoleCommands; + +/// +/// Debug console command for the Temporary HP mechanic. Modeled on the vanilla "heal" command. +/// +public class TempHpConsoleCmd : AbstractConsoleCmd +{ + /// + public override string CmdName => "temphp"; + + /// + public override string Args => " [index:int]"; + + /// + public override string Description => "Grant the player (or the ally at index) Temporary HP. A negative amount removes all of it."; + + /// + public override bool IsNetworked => true; + + /// + public override CmdResult Process(Player? issuingPlayer, string[] args) + { + if (args.Length < 1) + return new CmdResult(success: false, "An amount is required"); + if (!int.TryParse(args[0], out var amount)) + return new CmdResult(success: false, "First argument (the Temporary HP amount) must be an int."); + if (!RunManager.Instance.IsInProgress) + return new CmdResult(success: false, "A run does not appear to be in progress"); + if (!CombatManager.Instance.IsInProgress) + return new CmdResult(success: false, "Temporary HP can only be granted during combat."); + Creature creature; + if (args.Length > 1) + { + if (!int.TryParse(args[1], out var index)) + return new CmdResult(success: false, "Arg 2 must be the target index (int), got '" + args[1] + "'."); + var combatState = CombatManager.Instance.DebugOnlyGetState(); + if (combatState == null) + return new CmdResult(success: false, "No combat state available."); + IReadOnlyList allies = combatState.Allies; + if (index < 0 || index >= allies.Count) + return new CmdResult(success: false, $"Invalid target index {index}. Valid range: 0-{allies.Count - 1}"); + creature = allies[index]; + } + else + { + if (issuingPlayer == null) + return new CmdResult(success: false, "No issuing player; pass an ally index instead."); + creature = issuingPlayer.Creature; + } + if (amount < 0) + { + Task removeTask = TempHpCmd.RemoveAll(creature); + return new CmdResult(removeTask, success: true, $"Removed all Temporary HP from {creature}."); + } + Task addTask = TempHpCmd.Add(new ThrowingPlayerChoiceContext(), creature, amount); + return new CmdResult(addTask, success: true, $"Granted '{amount}' Temporary HP to {creature}."); + } +} diff --git a/Extensions/CreatureTempHpExtensions.cs b/Extensions/CreatureTempHpExtensions.cs new file mode 100644 index 00000000..31c086ab --- /dev/null +++ b/Extensions/CreatureTempHpExtensions.cs @@ -0,0 +1,29 @@ +using BaseLib.Commands; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Extensions; + +/// Creature extensions for the Temporary HP mechanic. See . +public static class CreatureTempHpExtensions +{ + /// The creature's current Temporary HP (0 if none). + public static int GetTempHp(this Creature creature) + { + return TempHpCmd.Get(creature); + } + + /// Whether the creature currently has any Temporary HP. + public static bool HasTempHp(this Creature creature) + { + return TempHpCmd.Get(creature) > 0; + } + + /// Grant Temporary HP. See . + public static Task AddTempHp(this Creature creature, PlayerChoiceContext choiceContext, int amount, + Creature? applier = null, CardModel? cardSource = null) + { + return TempHpCmd.Add(choiceContext, creature, amount, applier, cardSource); + } +} diff --git a/Hooks/ITempHpLossModifier.cs b/Hooks/ITempHpLossModifier.cs new file mode 100644 index 00000000..88c268ef --- /dev/null +++ b/Hooks/ITempHpLossModifier.cs @@ -0,0 +1,28 @@ +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.ValueProps; + +namespace BaseLib.Hooks; + +/// +/// Implement on a PowerModel or RelicModel to modify how much of a hit is applied to its owner's Temporary HP +/// (the StSLib OnLoseTempHpPower/OnLoseTempHpRelic equivalent). +/// Dispatched to the damaged creature's own powers and its player's relics, in that order, and only when the +/// creature actually has Temporary HP remaining. +/// Called synchronously inside the HP-loss pipeline: implementations MUST be pure — no awaits, no state +/// mutation, no VFX. +/// +public interface ITempHpLossModifier +{ + /// + /// Return the (possibly modified) damage amount that will be offered to Temporary HP. + /// Return 0 to make this hit skip Temporary HP entirely. + /// + /// Damage amount about to be absorbed (post-Block, post-Osty-redirect). + /// The creature whose Temporary HP is absorbing. + /// Properties of the damage. + /// Creature who dealt the damage, if any. + /// Card that dealt the damage, if any. + decimal ModifyTempHpLoss(decimal damageToTempHp, Creature target, ValueProp props, + Creature? dealer, CardModel? cardSource) => damageToTempHp; +} diff --git a/Patches/UI/TempHpDisplayPatch.cs b/Patches/UI/TempHpDisplayPatch.cs new file mode 100644 index 00000000..9573f326 --- /dev/null +++ b/Patches/UI/TempHpDisplayPatch.cs @@ -0,0 +1,195 @@ +using BaseLib.Commands; +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Nodes.Combat; +using MegaCrit.Sts2.Core.Nodes.HoverTips; +using MegaCrit.Sts2.addons.mega_text; + +namespace BaseLib.Patches.UI; + +/// +/// Health-bar display for Temporary HP (StSLib-style): a gold outline around the whole bar plus a gold +/// shield chip with the amount, mirroring the Block chip on the opposite (right) end of the bar. +/// All nodes are duplicated from the vanilla scene lazily on first use, so no custom assets are required; +/// vanilla nodes are never modified. Refreshing rides on the existing RefreshValues pipeline — power changes +/// already trigger it via CombatStateTracker (combat bars) and NMultiplayerPlayerState (MP side panel). +/// +[HarmonyPatch] +public static class TempHpDisplayPatch +{ + private static readonly SpireField UiStates = new(() => null); + + private static readonly Color GoldOutlineColor = StsColors.rewardLabelGoldOutline; + + /// StSLib's heart icon (a white heart, tinted gold like StSLib does), shipped in BaseLib.pck. + private const string HeartTexturePath = "BaseLib/images/ui/temp_hp.png"; + + private static Texture2D? _heartTexture; + private static bool _heartLoadAttempted; + + [HarmonyPatch(typeof(NHealthBar), "RefreshBlockUi")] + [HarmonyPostfix] + private static void RefreshBlockUiPostfix(NHealthBar __instance) + { + RefreshTempHpUi(__instance); + } + + [HarmonyPatch(typeof(NHealthBar), "SetHpBarContainerSizeWithOffsetsImmediately")] + [HarmonyPostfix] + private static void SetHpBarContainerSizeWithOffsetsImmediatelyPostfix(NHealthBar __instance) + { + RepositionAfterLayoutChange(__instance); + } + + [HarmonyPatch(typeof(NHealthBar), nameof(NHealthBar.UpdateLayoutForCreatureBounds))] + [HarmonyPostfix] + private static void UpdateLayoutForCreatureBoundsPostfix(NHealthBar __instance) + { + RepositionAfterLayoutChange(__instance); + } + + /// + /// The hidden power contributes no hover tips of its own (invisible powers never do), so the + /// "Temporary HP" keyword tip is appended at the creature level and shows when hovering the bar. + /// + [HarmonyPatch(typeof(Creature), nameof(Creature.HoverTips), MethodType.Getter)] + [HarmonyPostfix] + private static void HoverTipsPostfix(Creature __instance, ref IEnumerable __result) + { + if (TempHpCmd.Get(__instance) <= 0) + return; + __result = __result.Append(HoverTipFactory.Static(TempHpCmd.TempHp)); + } + + private static void RefreshTempHpUi(NHealthBar healthBar) + { + var creature = healthBar._creature; + if (creature == null) + return; + + var tempHp = TempHpCmd.Get(creature); + var state = UiStates[healthBar]; + if (tempHp <= 0 || creature.CurrentHp <= 0 || creature.HpDisplay.IsInfinite()) + { + if (state != null) + { + state.Outline.Visible = false; + state.Chip.Visible = false; + } + return; + } + + state ??= EnsureUiState(healthBar); + if (state == null) + return; + + state.Outline.Visible = true; + state.Chip.Visible = true; + state.Label.SetTextAutoSize(tempHp.ToString()); + PositionChip(healthBar, state); + } + + private static void RepositionAfterLayoutChange(NHealthBar healthBar) + { + var state = UiStates[healthBar]; + if (state is { Chip.Visible: true }) + PositionChip(healthBar, state); + } + + /// + /// Mirror of the vanilla Block chip rule: Block is centered on the bar's left edge + /// (see NHealthBar.UpdateLayoutForCreatureBounds), so the Temporary HP chip is centered on the right edge. + /// Computed from HpBarContainer in local space, which works for both combat bars (creature-bounds layout) + /// and the multiplayer side panel (reference-width layout). + /// + private static void PositionChip(NHealthBar healthBar, TempHpUiState state) + { + var barContainer = healthBar.HpBarContainer; + if (barContainer == null) + return; + state.Chip.Position = new Vector2( + barContainer.Position.X + barContainer.Size.X - state.Chip.Size.X * 0.5f, + healthBar._originalBlockPosition.Y); + } + + private static TempHpUiState? EnsureUiState(NHealthBar healthBar) + { + if (UiStates[healthBar] is { } existing) + return existing; + + var blockOutline = healthBar._blockOutline; + var blockContainer = healthBar._blockContainer; + if (blockOutline == null || blockContainer == null) + return null; // _Ready hasn't run yet + if (blockOutline.GetParent() is not Control barContainer || + blockContainer.GetParent() is not Control chipParent) + return null; + + // Gold outline around the whole bar, layered right above the blue Block outline. + var outline = (Control)blockOutline.Duplicate(); + outline.Name = "BaseLibTempHpOutline"; + outline.Visible = false; + outline.Modulate = Colors.White; + outline.SelfModulate = StsColors.gold; + outline.MouseFilter = Control.MouseFilterEnum.Ignore; + barContainer.AddChild(outline); + barContainer.MoveChild(outline, blockOutline.GetIndex() + 1); + + // Gold shield chip with the amount, duplicated from the Block chip. + var chip = (Control)blockContainer.Duplicate(); + chip.Name = "BaseLibTempHpContainer"; + chip.Visible = false; + var icon = chip.GetNodeOrNull("BlockIcon"); + var label = chip.GetNodeOrNull("BlockLabel"); + if (icon == null || label == null) + { + outline.QueueFreeSafely(); + chip.QueueFreeSafely(); + return null; + } + if (TryLoadHeartTexture() is { } heartTexture) + icon.Texture = heartTexture; // fallback: keep the duplicated block.png if the pck texture is missing + icon.SelfModulate = StsColors.gold; + label.AddThemeColorOverride(ThemeConstants.Label.FontColor, StsColors.gold); + label.AddThemeColorOverride(ThemeConstants.Label.FontOutlineColor, GoldOutlineColor); + chip.MouseFilter = Control.MouseFilterEnum.Stop; + // CreateAndShow does NOT position the tip set itself — vanilla callers set the position explicitly + // (see NMultiplayerPlayerState's network-problem tip); without this it lands at the screen origin. + chip.MouseEntered += () => NHoverTipSet.CreateAndShow(chip, HoverTipFactory.Static(TempHpCmd.TempHp)) + ?.SetGlobalPosition(chip.GlobalPosition + Vector2.Down * 80f); + chip.MouseExited += () => NHoverTipSet.Remove(chip); + chipParent.AddChild(chip); + + var state = new TempHpUiState(outline, chip, label); + UiStates[healthBar] = state; + return state; + } + + private static Texture2D? TryLoadHeartTexture() + { + if (_heartLoadAttempted) + return _heartTexture; + _heartLoadAttempted = true; + try + { + _heartTexture = PreloadManager.Cache.GetTexture2D(HeartTexturePath); + } + catch (Exception e) + { + BaseLibMain.Logger.Warn($"Failed to load {HeartTexturePath}, falling back to the block icon: {e.Message}"); + } + return _heartTexture; + } + + private sealed class TempHpUiState(Control outline, Control chip, MegaLabel label) + { + public Control Outline { get; } = outline; + public Control Chip { get; } = chip; + public MegaLabel Label { get; } = label; + } +} diff --git a/Powers/TempHpPower.cs b/Powers/TempHpPower.cs new file mode 100644 index 00000000..490d7d15 --- /dev/null +++ b/Powers/TempHpPower.cs @@ -0,0 +1,145 @@ +using BaseLib.Abstracts; +using BaseLib.Commands; +using BaseLib.Hooks; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Powers; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.ValueProps; + +namespace BaseLib.Powers; + +/// +/// Temporary HP (port of StSLib's mechanic): a hidden per-creature pool that absorbs any HP loss going through +/// the damage pipeline. Resolution order: Block → Osty redirect → Temporary HP (early AfterOsty pass) → +/// late-pass buffs (ModifyHpLostAfterOstyLate, e.g. Buffer-style prevention) → real HP. Because absorption +/// happens after the redirect, it is the FINAL receiver's pool that absorbs (redirected damage consumes Osty's +/// Temporary HP; overkill trampling back consumes the original target's). +/// Removed automatically at end of combat and on death, like any power. +/// Grant/query through or the Creature extensions; do not apply this power directly +/// unless you want to skip the gain FX. +/// +public sealed class TempHpPower : CustomPowerModel +{ + /// + public override PowerType Type => PowerType.Buff; + + /// + public override PowerStackType StackType => PowerStackType.Counter; + + /// Hidden from the power row; rendered on the health bar instead (see TempHpDisplayPatch). + /// Invisibility also exempts it from Artifact, which only blocks visible debuffs. + protected override bool IsVisibleInternal => false; + + /// Absorption is computed in the pure ModifyHpLost phase but applied in AfterDamageReceived, which runs after + /// every HP mutation of the same Damage() call has completed. Only nonzero within a single damage resolution; + /// never serialized (power serialization is (ModelId, Amount) only). + private int _pendingAbsorb; + + /// + protected override void AfterCloned() + { + base.AfterCloned(); + _pendingAbsorb = 0; + } + + /// + /// Early AfterOsty pass: after Block and after the Osty redirect (target here is the final damage receiver), + /// but before all late-pass HP-loss hooks — Buffer-style preventers implementing ModifyHpLostAfterOstyLate + /// are guaranteed to run after Temporary HP. Relative order versus other EARLY-pass effects on the same + /// creature (e.g. vanilla Intangible) follows power application order. Must stay pure — the pool is only + /// debited later, in . + /// + public override decimal ModifyHpLostAfterOsty(Creature target, decimal amount, ValueProp props, Creature? dealer, CardModel? cardSource) + { + if (!CombatManager.Instance.IsInProgress) + return amount; + if (target != Owner || amount < 1m) + return amount; + if (props.HasFlag(TempHpCmd.IgnoresTempHp)) + return amount; + // The same creature can enter this pass twice within one Damage() call (multi-target redirects, and the + // overkill-trample-back AfterOsty call for the original target), hence the remaining-pool cap. + var remaining = Amount - _pendingAbsorb; + if (remaining <= 0) + return amount; + + var toTempHp = amount; + foreach (var power in Owner.Powers) + { + if (power is ITempHpLossModifier modifier) + toTempHp = modifier.ModifyTempHpLoss(toTempHp, Owner, props, dealer, cardSource); + } + var relics = Owner.Player?.Relics; + if (relics != null) + { + foreach (var relic in relics) + { + if (relic is ITempHpLossModifier modifier) + toTempHp = modifier.ModifyTempHpLoss(toTempHp, Owner, props, dealer, cardSource); + } + } + + var absorbed = (int)Math.Min(decimal.Truncate(toTempHp), remaining); + if (absorbed <= 0) + return amount; + _pendingAbsorb += absorbed; + return amount - absorbed; + } + + /// + /// Applies the absorption computed during the ModifyHpLost phase of the same Damage() call. + /// Dispatched once per DamageResult after ALL HP mutations of the call have completed; the results loop + /// fires it with the result whose Receiver is this pool's owner (redirect and overkill-trample cases each + /// produce their own result), so filtering on Receiver == Owner commits exactly once. + /// + public override async Task AfterDamageReceived(PlayerChoiceContext choiceContext, Creature target, DamageResult result, ValueProp props, Creature? dealer, CardModel? cardSource) + { + if (result.Receiver != Owner || _pendingAbsorb == 0) + return; + await ApplyPendingAbsorb(silent: false, playFx: true, result); + } + + /// + /// A killed target skips AfterDamageReceived entirely, but death can still be prevented (Fairy in a Bottle + /// etc.). Flushing here — before the death-prevention decision — keeps the pool consistent either way. + /// + public override Task BeforeDeath(Creature creature) + { + if (creature == Owner && _pendingAbsorb > 0) + return ApplyPendingAbsorb(silent: true, playFx: false, null); + return Task.CompletedTask; + } + + private async Task ApplyPendingAbsorb(bool silent, bool playFx, DamageResult? source) + { + var lost = Math.Min(_pendingAbsorb, Amount); + _pendingAbsorb = 0; + if (lost <= 0) + return; + // SetAmount instead of PowerCmd.ModifyAmount: the debit must equal the absorbed damage exactly, without + // being re-routed through the ModifyPowerAmountGiven/Received hook bus. + SetAmount(Amount - lost, silent); + TempHpCmd.InvokeLost(Owner, lost, Amount, source); + if (playFx) + TempHpCmd.PlayAbsorbFx(Owner, lost); + if (Amount <= 0) + await PowerCmd.Remove(this); // hook dispatch iterates a snapshot list, so removal mid-dispatch is safe + } + + /// Forwards gains (and foreign PowerCmd changes) to the events. + public override Task AfterPowerAmountChanged(PlayerChoiceContext choiceContext, PowerModel power, decimal amount, Creature? applier, CardModel? cardSource) + { + if (power != this) + return Task.CompletedTask; + // Covers PowerCmd.Apply (fresh application and stacking) and foreign PowerCmd.ModifyAmount calls. + // Our own SetAmount debit does not re-enter this hook, so absorption is never double-reported. + if (amount > 0m) + TempHpCmd.InvokeGained(Owner, (int)amount, Amount); + else if (amount < 0m) + TempHpCmd.InvokeLost(Owner, (int)(-amount), Amount, null); + return Task.CompletedTask; + } +}