From 04f139c5b2d19d7ecb0cf7a99ea0bc97d7a07730 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:51:17 +0200 Subject: [PATCH 1/9] feature implemented first working version of the feature. Cleanup follows --- Abstracts/CustomLinkedRewardSet.cs | 191 ++++++++++++++++++++++++ BaseLib/scenes/linked_reward_set.tscn | 45 ++++++ BaseLibScenes/NCustomLinkedRewardSet.cs | 162 ++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 Abstracts/CustomLinkedRewardSet.cs create mode 100644 BaseLib/scenes/linked_reward_set.tscn create mode 100644 BaseLibScenes/NCustomLinkedRewardSet.cs diff --git a/Abstracts/CustomLinkedRewardSet.cs b/Abstracts/CustomLinkedRewardSet.cs new file mode 100644 index 00000000..54873b13 --- /dev/null +++ b/Abstracts/CustomLinkedRewardSet.cs @@ -0,0 +1,191 @@ +using System.Diagnostics.CodeAnalysis; +using BaseLib.Patches.Content; +using BaseLib.Patches.Saves; +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Multiplayer.Serialization; +using MegaCrit.Sts2.Core.Nodes.Rewards; +using MegaCrit.Sts2.Core.Rewards; +using MegaCrit.Sts2.Core.Saves.Runs; + +namespace BaseLib.Abstracts; + +/// +/// We do not use BaseGame rewards because they are slightly buggy, and we are providing additional features which is too much work to patch in. +/// +public class CustomLinkedRewardSet : CustomReward +{ + // Do not rename, move, touch, anything to this field. We use it deterministically through the hash algorithm. + [CustomEnum] public static RewardType CustomLinkedRewardType; + private const string SaveId = "baselib_customlinkedrewardset_children"; + + private static LocString HoverTipTitle => new LocString("static_hover_tips", "LINKED_REWARDS.title"); + private static LocString HoverTipDesc => new LocString("static_hover_tips", "LINKED_REWARDS.description"); + + private static readonly SpireField ParentCustomLinkedRewardSet = new(() => null); + + public static bool TryGetCustomLinkedRewardSet(Reward reward, [NotNullWhen(true)] out CustomLinkedRewardSet? customLinkedRewardSet) + { + customLinkedRewardSet = ParentCustomLinkedRewardSet.Get(reward); + return customLinkedRewardSet != null; + } + + + private List _rewards; + public IReadOnlyList Rewards => _rewards.ToList(); + + protected override RewardType RewardType => CustomLinkedRewardType; + public override int RewardsSetIndex => Rewards.Max((Reward r) => r.RewardsSetIndex); + public override CreateRewardFromSave DeserializeMethod => CreateFromSerializable; + public override LocString Description => new LocString("gameplay_ui", "COMBAT_REWARD_LINKED"); + public override bool IsPopulated => _rewards.All((Reward r) => r.IsPopulated); + + private LinkedRewardType _linkedRewardType; + public LinkedRewardType LinkedRewardType => _linkedRewardType; + + private CustomLinkedRewardSet() : base(null!) + { + _rewards = []; + } + public CustomLinkedRewardSet(List rewards, Player player, LinkedRewardType linkedRewardType = LinkedRewardType.Exclusive) : base(player) + { + _rewards = rewards; + _linkedRewardType = linkedRewardType; + foreach (var reward in _rewards) + //reward.ParentRewardSet = this; // OLD + ParentCustomLinkedRewardSet.Set(reward, this); + + } + + public override void Populate() + { + foreach (var reward in _rewards) + reward.Populate(); + + } + + protected override Task OnSelect() + { + return Task.FromResult(result: true); + } + + public override void MarkContentAsSeen() + { + foreach (var reward in _rewards) + reward.MarkContentAsSeen(); + + } + + public override void OnSkipped() + { + foreach (var reward in _rewards) + reward.OnSkipped(); + + } + + public void RemoveReward(Reward reward) + { + _rewards.Remove(reward); + } + + + // Called by the sideload setter below once the nested rewards have been + // reconstructed from save data. + internal void RestoreRewards(List rewards, LinkedRewardType linkedRewardType) + { + _rewards = rewards; + _linkedRewardType = linkedRewardType; + foreach (var reward in _rewards) + ParentCustomLinkedRewardSet.Set(reward, this); + } + + // --- CustomReward hookup --- + + // Builds an empty placeholder; ExtendedSaveHandlers' existing postfix on + // Reward.FromSerializable calls our registered setter right after this returns, + // which is what actually fills in _rewards via RestoreRewards. + public static CustomReward CreateFromSerializable(SerializableReward save, Player player) + => new CustomLinkedRewardSet([], player); + + + public override void Initialize() + { + base.Initialize(); // registers DeserializeMethod against CustomLinkedRewardType + ExtendedSaveTypes.RegisterObjectSaveType( + ExtendedSaveTypes.PropertyFunc>( + nameof(SerializableLinkedRewardData.Rewards)), + ExtendedSaveTypes.PropertyFunc( + nameof(SerializableLinkedRewardData.LinkedRewardTypeValue)) + ); + ExtendedSaveHandlers.RegisterSave( + SaveId, + getter: reward => reward is CustomLinkedRewardSet clrs + ? new SerializableLinkedRewardData + { + Rewards = clrs.Rewards.Select(r => r.ToSerializable()).ToList(), + LinkedRewardTypeValue = (int)clrs.LinkedRewardType + } + : null, + setter: (reward, value) => + { + if (reward is not CustomLinkedRewardSet clrs || value == null) return; + var restored = value.Rewards.Select(sr => Reward.FromSerializable(sr, reward.Player)).ToList(); + clrs.RestoreRewards(restored, (LinkedRewardType)value.LinkedRewardTypeValue ); + }); + } +} + + + +public enum LinkedRewardType +{ + None, + Exclusive, + Bundled +} + +// Small wrapper so a List (+ LinkedRewardType) can ride along +// as a sideloaded value via ExtendedSaveHandlers. Mirrors the pattern the base game +// uses for CombatRoom.ExtraRewards. +public class SerializableLinkedRewardData : IPacketSerializable +{ + public List Rewards { get; set; } = []; + public int LinkedRewardTypeValue { get; set; } // stored as int, not the enum directly + + + public void Serialize(PacketWriter writer) + { + writer.WriteList(Rewards); + writer.WriteInt(LinkedRewardTypeValue); + } + + public void Deserialize(PacketReader reader) + { + Rewards = reader.ReadList(); + LinkedRewardTypeValue = reader.ReadInt(); + } +} + +[HarmonyPatch] +public static class CustomLinkedRewardSetPatches +{ + // TODO: Check what would happen. Eventually remove or replace with throwing an error + [HarmonyPatch(typeof(NRewardButton), "SetReward")] + [HarmonyPrefix] + private static void WarnOfUndefinedBehaviour(Reward reward) + { + if(reward is CustomLinkedRewardSet) + BaseLibMain.Logger.Warn($"The Reward inside an NRewardButton has been set to an object of \"CustomLinkedRewardSet\". This is undefined behaviour (base game would throw an error here). Proceed at your own risk."); + } + + [HarmonyPatch(typeof(Reward), "HoverTips", MethodType.Getter)] + [HarmonyPostfix] + private static void AddHoverTip(Reward __instance, ref IEnumerable __result) + { + if (!CustomLinkedRewardSet.TryGetCustomLinkedRewardSet(__instance, out var customLinkedRewardSet)) return; + __result = [.. __result, .. customLinkedRewardSet.HoverTips]; + } +} \ No newline at end of file diff --git a/BaseLib/scenes/linked_reward_set.tscn b/BaseLib/scenes/linked_reward_set.tscn new file mode 100644 index 00000000..ca0b662d --- /dev/null +++ b/BaseLib/scenes/linked_reward_set.tscn @@ -0,0 +1,45 @@ +[gd_scene load_steps=2 format=3 uid="uid://bg6wy5uy5jriq"] + +[ext_resource type="Script" uid="uid://7xryq86mhqfn" path="res://BaseLibScenes/NCustomLinkedRewardSet.cs" id="1_2c5wt"] + +[node name="LinkedRewardSet" type="MarginContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_2c5wt") + +[node name="ColorRect" type="ColorRect" parent="."] +layout_mode = 2 +color = Color(0.078431375, 0.12941177, 0.15294118, 1) + +[node name="MarginContainer" type="MarginContainer" parent="."] +layout_mode = 2 +theme_override_constants/margin_left = 10 +theme_override_constants/margin_top = 15 +theme_override_constants/margin_right = 10 +theme_override_constants/margin_bottom = 15 + +[node name="RewardContainer" type="VBoxContainer" parent="MarginContainer"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="Control" type="Control" parent="."] +layout_mode = 2 +mouse_filter = 2 + +[node name="ChainContainer" type="Control" parent="Control"] +unique_name_in_owner = true +layout_mode = 1 +anchors_preset = 13 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -41.0 +offset_top = 48.0 +offset_right = 59.0 +offset_bottom = 11.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs new file mode 100644 index 00000000..0d8244d6 --- /dev/null +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -0,0 +1,162 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Abstracts; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Assets; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Nodes.Rewards; +using MegaCrit.Sts2.Core.Nodes.Screens; +using MegaCrit.Sts2.Core.Rewards; + +namespace BaseLib.BaseLibScenes; + +[GlobalClass] +public partial class NCustomLinkedRewardSet : Control +{ + public static readonly StringName RewardClaimedSignalName = "RewardClaimed"; + + [Signal] + public delegate void RewardClaimedEventHandler(NCustomLinkedRewardSet customLinkedRewardSet); + + private NRewardsScreen _rewardsScreen; + + private Control _rewardContainer; + + private Control _chainsContainer; + + public CustomLinkedRewardSet CustomLinkedRewardSet { get; private set; } + + //private static string ScenePath => SceneHelper.GetScenePath("/rewards/linked_reward_set"); + private static string ScenePath => $"res://BaseLib/scenes/linked_reward_set.tscn"; + + private static string ChainImagePath => ImageHelper.GetImagePath("/ui/reward_screen/reward_chain.png"); + + public static IEnumerable AssetPaths => new string[2] { ScenePath, ChainImagePath }; + + private bool _grabbedReward = false; + private List rewardButtons = new(); + + + public override void _Ready() + { + _rewardContainer = GetNode("%RewardContainer"); + _chainsContainer = GetNode("%ChainContainer"); + Reload(); + } + + public static NCustomLinkedRewardSet Create(CustomLinkedRewardSet linkedReward, NRewardsScreen screen) + { + var nCustomLinkedRewardSet = PreloadManager.Cache.GetScene(ScenePath).Instantiate(PackedScene.GenEditState.Disabled); + nCustomLinkedRewardSet._rewardsScreen = screen; + nCustomLinkedRewardSet.SetReward(linkedReward); + return nCustomLinkedRewardSet; + } + + private void SetReward(CustomLinkedRewardSet linkedReward) + { + CustomLinkedRewardSet = linkedReward; + if (IsNodeReady()) + { + Reload(); + } + } + + private void Reload() + { + if (!IsNodeReady()) + { + return; + } + rewardButtons.Clear(); + for (var i = 0; i < CustomLinkedRewardSet.Rewards.Count; i++) + { + var reward = CustomLinkedRewardSet.Rewards[i]; + var nRewardButton = NRewardButton.Create(reward, _rewardsScreen); + rewardButtons.Add(nRewardButton); + nRewardButton.CustomMinimumSize -= Vector2.Right * 20f; + _rewardContainer.AddChildSafely(nRewardButton); + nRewardButton.Connect(NRewardButton.SignalName.RewardClaimed, Callable.From(GetReward)); + if (i >= CustomLinkedRewardSet.Rewards.Count - 1) continue; + var textureRect = new TextureRect(); + textureRect.MouseFilter = MouseFilterEnum.Ignore; + textureRect.Texture = PreloadManager.Cache.GetCompressedTexture2D(ChainImagePath); + textureRect.Size = Vector2.One * 50f; + _chainsContainer.AddChildSafely(textureRect); + textureRect.GlobalPosition = _chainsContainer.GlobalPosition + Vector2.Down * i * (3f + nRewardButton.CustomMinimumSize.Y); + } + } + + private void GetReward(NRewardButton button) + { + if (CustomLinkedRewardSet.LinkedRewardType == LinkedRewardType.Bundled) + { + if (_grabbedReward) return; + _grabbedReward = true; + rewardButtons.Remove(button); + foreach (var rewardButton in rewardButtons) + { + rewardButton.GetReward(); + } + } + //_rewardsScreen.RewardCollectedFrom(this); + CustomLinkedRewardSet.OnSkipped(); + EmitSignal(RewardClaimedSignalName, this); + this.QueueFreeSafely(); + } +} + +[HarmonyPatch] +public static class NCustomLinkedRewardSetPatches +{ + [HarmonyTranspiler] + [HarmonyPatch(typeof(NRewardsScreen), "_Ready")] + private static IEnumerable ReplaceConnectMethodWithCustom(IEnumerable instructions, MethodBase originalMethod) + { + var customLinkedRewardSetCheck = AccessTools.Method(typeof(NCustomLinkedRewardSetPatches), nameof(CustomLinkedRewardSetCheck)); + var nRewardButtonCreate = AccessTools.Method(typeof(NRewardButton), nameof(NRewardButton.Create)); + + var matcher = new CodeMatcher(instructions) + // Anchor on the unique call that starts the `else` branch: + // option = NRewardButton.Create(item, this); + .MatchStartForward(new CodeMatch(OpCodes.Call, nRewardButtonCreate)) + .ThrowIfInvalid("Could not find NRewardButton.Create call (else-branch start)"); + + // Step back to the else-branch's real first instruction: ldloc.s V_7 + matcher.Advance(-3); + + var optionField = (FieldInfo)matcher.InstructionAt(4).operand; // grab the real field, no guessing + var endLabel = matcher.InstructionAt(-1).operand; // the preceding br.s's target = IL_037f + + // This instruction is the actual jump target for the `if` check (item is LinkedRewardSet == false). + // Detach its label so we can move it onto our own first instruction instead. + var elseEntryLabels = new List(matcher.Labels); + matcher.Labels.Clear(); + + var loadDisplayClass = matcher.Instruction.Clone().WithLabels(elseEntryLabels); // ldloc.s V_7 (now the real entry point) + var loadRewardItem = matcher.InstructionAt(1).Clone(); // ldloc.s reward + + var newInstructions = new List + { + loadDisplayClass, // push V_7 + new CodeInstruction(OpCodes.Ldflda, optionField), // push &V_7.option + loadRewardItem, // push reward + new CodeInstruction(OpCodes.Ldarg_0), // push this + new CodeInstruction(OpCodes.Call, customLinkedRewardSetCheck), + new CodeInstruction(OpCodes.Brtrue, endLabel), // handled -> skip the rest of the else-branch + }; + + matcher.Insert(newInstructions); // insert before the (now unlabeled) original else-branch code + + return matcher.InstructionEnumeration(); + } + + + private static bool CustomLinkedRewardSetCheck(ref Control option, Reward item, NRewardsScreen screen) + { + if (item is not CustomLinkedRewardSet clrs) return false; + option = NCustomLinkedRewardSet.Create(clrs, screen); + option.Connect(NCustomLinkedRewardSet.RewardClaimedSignalName, Callable.From(screen.RewardCollectedFrom)); + return true; + } +} \ No newline at end of file From 35c75b8e5059fb4ea2109eb5aed2729415535b89 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:36:01 +0200 Subject: [PATCH 2/9] Add visul indicators as to which type it is --- Abstracts/CustomLinkedRewardSet.cs | 99 +++++++------ .../localization/eng/static_hover_tips.json | 7 +- BaseLibScenes/NCustomLinkedRewardSet.cs | 131 +++++++++++++++--- BaseLibScenes/NRewardHighlight.cs | 73 ++++++++++ 4 files changed, 244 insertions(+), 66 deletions(-) create mode 100644 BaseLibScenes/NRewardHighlight.cs diff --git a/Abstracts/CustomLinkedRewardSet.cs b/Abstracts/CustomLinkedRewardSet.cs index 54873b13..e4d5b008 100644 --- a/Abstracts/CustomLinkedRewardSet.cs +++ b/Abstracts/CustomLinkedRewardSet.cs @@ -18,13 +18,16 @@ namespace BaseLib.Abstracts; /// public class CustomLinkedRewardSet : CustomReward { - // Do not rename, move, touch, anything to this field. We use it deterministically through the hash algorithm. [CustomEnum] public static RewardType CustomLinkedRewardType; private const string SaveId = "baselib_customlinkedrewardset_children"; - private static LocString HoverTipTitle => new LocString("static_hover_tips", "LINKED_REWARDS.title"); - private static LocString HoverTipDesc => new LocString("static_hover_tips", "LINKED_REWARDS.description"); - + private LocString HoverTipTitle => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled + ? "BASELIB-BUNDLED_REWARDS.title" : "BASELIB-EXCLUSIVE_REWARDS.title"); + private LocString HoverTipDesc => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled + ? "BASELIB-BUNDLED_REWARDS.description" : "BASELIB-EXCLUSIVE_REWARDS.description"); + + public HoverTip HoverTip => new(HoverTipTitle, HoverTipDesc); + private static readonly SpireField ParentCustomLinkedRewardSet = new(() => null); public static bool TryGetCustomLinkedRewardSet(Reward reward, [NotNullWhen(true)] out CustomLinkedRewardSet? customLinkedRewardSet) @@ -38,26 +41,38 @@ public static bool TryGetCustomLinkedRewardSet(Reward reward, [NotNullWhen(true) public IReadOnlyList Rewards => _rewards.ToList(); protected override RewardType RewardType => CustomLinkedRewardType; - public override int RewardsSetIndex => Rewards.Max((Reward r) => r.RewardsSetIndex); + public override int RewardsSetIndex => Rewards.Max(r => r.RewardsSetIndex); public override CreateRewardFromSave DeserializeMethod => CreateFromSerializable; - public override LocString Description => new LocString("gameplay_ui", "COMBAT_REWARD_LINKED"); - public override bool IsPopulated => _rewards.All((Reward r) => r.IsPopulated); + public override LocString Description => new("gameplay_ui", "COMBAT_REWARD_LINKED"); + public override bool IsPopulated => _rewards.All(r => r.IsPopulated); private LinkedRewardType _linkedRewardType; public LinkedRewardType LinkedRewardType => _linkedRewardType; + + /// + /// Exists only for BaseLib Save logic registration.
+ /// Do not use. + ///
private CustomLinkedRewardSet() : base(null!) { _rewards = []; } + public CustomLinkedRewardSet(List rewards, Player player, LinkedRewardType linkedRewardType = LinkedRewardType.Exclusive) : base(player) { _rewards = rewards; _linkedRewardType = linkedRewardType; foreach (var reward in _rewards) - //reward.ParentRewardSet = this; // OLD ParentCustomLinkedRewardSet.Set(reward, this); - + } + + private void RestoreRewards(List rewards, LinkedRewardType linkedRewardType) + { + _rewards = rewards; + _linkedRewardType = linkedRewardType; + foreach (var reward in _rewards) + ParentCustomLinkedRewardSet.Set(reward, this); } public override void Populate() @@ -91,20 +106,10 @@ public void RemoveReward(Reward reward) _rewards.Remove(reward); } - - // Called by the sideload setter below once the nested rewards have been - // reconstructed from save data. - internal void RestoreRewards(List rewards, LinkedRewardType linkedRewardType) - { - _rewards = rewards; - _linkedRewardType = linkedRewardType; - foreach (var reward in _rewards) - ParentCustomLinkedRewardSet.Set(reward, this); - } + - // --- CustomReward hookup --- - - // Builds an empty placeholder; ExtendedSaveHandlers' existing postfix on + + // Builds an empty placeholder. 'ExtendedSaveHandlers' existing postfix on // Reward.FromSerializable calls our registered setter right after this returns, // which is what actually fills in _rewards via RestoreRewards. public static CustomReward CreateFromSerializable(SerializableReward save, Player player) @@ -113,23 +118,23 @@ public static CustomReward CreateFromSerializable(SerializableReward save, Playe public override void Initialize() { - base.Initialize(); // registers DeserializeMethod against CustomLinkedRewardType - ExtendedSaveTypes.RegisterObjectSaveType( - ExtendedSaveTypes.PropertyFunc>( - nameof(SerializableLinkedRewardData.Rewards)), - ExtendedSaveTypes.PropertyFunc( - nameof(SerializableLinkedRewardData.LinkedRewardTypeValue)) + base.Initialize(); + ExtendedSaveTypes.RegisterObjectSaveType( + ExtendedSaveTypes.PropertyFunc>( + nameof(SerializableCustomLinkedRewardData.Rewards)), + ExtendedSaveTypes.PropertyFunc( + nameof(SerializableCustomLinkedRewardData.LinkedRewardTypeValue)) ); - ExtendedSaveHandlers.RegisterSave( + ExtendedSaveHandlers.RegisterSave( SaveId, - getter: reward => reward is CustomLinkedRewardSet clrs - ? new SerializableLinkedRewardData + reward => reward is CustomLinkedRewardSet clrs + ? new SerializableCustomLinkedRewardData { Rewards = clrs.Rewards.Select(r => r.ToSerializable()).ToList(), LinkedRewardTypeValue = (int)clrs.LinkedRewardType } : null, - setter: (reward, value) => + (reward, value) => { if (reward is not CustomLinkedRewardSet clrs || value == null) return; var restored = value.Rewards.Select(sr => Reward.FromSerializable(sr, reward.Player)).ToList(); @@ -139,21 +144,30 @@ public override void Initialize() } - +/// +/// Reward gain rules for the Linked Reward +/// public enum LinkedRewardType { + /// + /// Do not use + /// None, + /// + /// You may only choose 1 option + /// Exclusive, + /// + /// You either choose All or No options + /// Bundled } -// Small wrapper so a List (+ LinkedRewardType) can ride along -// as a sideloaded value via ExtendedSaveHandlers. Mirrors the pattern the base game -// uses for CombatRoom.ExtraRewards. -public class SerializableLinkedRewardData : IPacketSerializable + +public class SerializableCustomLinkedRewardData : IPacketSerializable { public List Rewards { get; set; } = []; - public int LinkedRewardTypeValue { get; set; } // stored as int, not the enum directly + public int LinkedRewardTypeValue { get; set; } public void Serialize(PacketWriter writer) @@ -172,13 +186,12 @@ public void Deserialize(PacketReader reader) [HarmonyPatch] public static class CustomLinkedRewardSetPatches { - // TODO: Check what would happen. Eventually remove or replace with throwing an error [HarmonyPatch(typeof(NRewardButton), "SetReward")] [HarmonyPrefix] - private static void WarnOfUndefinedBehaviour(Reward reward) + private static void ThrowIfMisuseOfCustomRewardSet(Reward reward) { if(reward is CustomLinkedRewardSet) - BaseLibMain.Logger.Warn($"The Reward inside an NRewardButton has been set to an object of \"CustomLinkedRewardSet\". This is undefined behaviour (base game would throw an error here). Proceed at your own risk."); + throw new ArgumentException("You aren't allowed to apply a CustomLinkedRewardSet to an NRewardButton"); } [HarmonyPatch(typeof(Reward), "HoverTips", MethodType.Getter)] @@ -186,6 +199,8 @@ private static void WarnOfUndefinedBehaviour(Reward reward) private static void AddHoverTip(Reward __instance, ref IEnumerable __result) { if (!CustomLinkedRewardSet.TryGetCustomLinkedRewardSet(__instance, out var customLinkedRewardSet)) return; - __result = [.. __result, .. customLinkedRewardSet.HoverTips]; + var list = __result.ToList(); + list.Add(customLinkedRewardSet.HoverTip); + __result = list; } } \ No newline at end of file diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json index ed6c49da..b3cc7229 100644 --- a/BaseLib/localization/eng/static_hover_tips.json +++ b/BaseLib/localization/eng/static_hover_tips.json @@ -4,5 +4,10 @@ "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-BUNDLED_REWARDS.title": "Bundled Rewards", + "BASELIB-BUNDLED_REWARDS.description": "You can only select [gold]All[/gold] or [gold]No[/gold] reward from this set.", + "BASELIB-EXCLUSIVE_REWARDS.title": "Exclusive Rewards", + "BASELIB-EXCLUSIVE_REWARDS.description": "You can only select [blue]1[/blue] reward from this set." } \ No newline at end of file diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs index 0d8244d6..1a6d49a2 100644 --- a/BaseLibScenes/NCustomLinkedRewardSet.cs +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Reflection.Emit; using BaseLib.Abstracts; +using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Assets; @@ -19,32 +20,58 @@ public partial class NCustomLinkedRewardSet : Control [Signal] public delegate void RewardClaimedEventHandler(NCustomLinkedRewardSet customLinkedRewardSet); - private NRewardsScreen _rewardsScreen; + /// + /// Similar to only this class uses this. But technically anyone can now make use of the highlighting. + /// Including other modders.
+ /// Every NRewardButton gets the highlight node added, not just those used by CustomLinkedRewards. + ///
+ public static AddedNode NHighlights = new((rewardButton) => + { + var textureRec = new NRewardHighlight(); + textureRec.Texture = PreloadManager.Cache.GetCompressedTexture2D("res://images/packed/card_template/card_frame_sdf.exr"); + rewardButton.AddChildSafely(textureRec); + rewardButton.MoveChild(textureRec, rewardButton.GetNode("%Background").GetIndex()); + textureRec.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize; + textureRec.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered; + textureRec.Modulate = new Color("00f4fcfa"); + textureRec.Scale = new Vector2(1.8f, 0.3f); + textureRec.Position = new Vector2(-270f, -33f); + textureRec.MouseFilter = MouseFilterEnum.Ignore; + return textureRec; + }); - private Control _rewardContainer; + private NRewardsScreen _rewardsScreen; + private Control _rewardContainer; private Control _chainsContainer; public CustomLinkedRewardSet CustomLinkedRewardSet { get; private set; } - //private static string ScenePath => SceneHelper.GetScenePath("/rewards/linked_reward_set"); private static string ScenePath => $"res://BaseLib/scenes/linked_reward_set.tscn"; + + + // The game groups some assets in AssetSets which exists to not clutter PreloadManager. We aren't using it yet. + // public static IEnumerable AssetPaths => [ScenePath]; - private static string ChainImagePath => ImageHelper.GetImagePath("/ui/reward_screen/reward_chain.png"); - - public static IEnumerable AssetPaths => new string[2] { ScenePath, ChainImagePath }; - - private bool _grabbedReward = false; - private List rewardButtons = new(); + private bool _signalAlreadyReceived = false; + private readonly List _rewardButtons = []; public override void _Ready() { _rewardContainer = GetNode("%RewardContainer"); _chainsContainer = GetNode("%ChainContainer"); + NRewardButtonEvents.Focused += OnFocused; + NRewardButtonEvents.Unfocused += OnUnfocused; Reload(); } + public override void _ExitTree() + { + NRewardButtonEvents.Focused -= OnFocused; + NRewardButtonEvents.Unfocused -= OnUnfocused; + } + public static NCustomLinkedRewardSet Create(CustomLinkedRewardSet linkedReward, NRewardsScreen screen) { var nCustomLinkedRewardSet = PreloadManager.Cache.GetScene(ScenePath).Instantiate(PackedScene.GenEditState.Disabled); @@ -68,44 +95,102 @@ private void Reload() { return; } - rewardButtons.Clear(); + _rewardButtons.Clear(); for (var i = 0; i < CustomLinkedRewardSet.Rewards.Count; i++) { var reward = CustomLinkedRewardSet.Rewards[i]; var nRewardButton = NRewardButton.Create(reward, _rewardsScreen); - rewardButtons.Add(nRewardButton); + _rewardButtons.Add(nRewardButton); nRewardButton.CustomMinimumSize -= Vector2.Right * 20f; _rewardContainer.AddChildSafely(nRewardButton); nRewardButton.Connect(NRewardButton.SignalName.RewardClaimed, Callable.From(GetReward)); - if (i >= CustomLinkedRewardSet.Rewards.Count - 1) continue; - var textureRect = new TextureRect(); - textureRect.MouseFilter = MouseFilterEnum.Ignore; - textureRect.Texture = PreloadManager.Cache.GetCompressedTexture2D(ChainImagePath); - textureRect.Size = Vector2.One * 50f; - _chainsContainer.AddChildSafely(textureRect); - textureRect.GlobalPosition = _chainsContainer.GlobalPosition + Vector2.Down * i * (3f + nRewardButton.CustomMinimumSize.Y); } } + private void OnFocused(NRewardButton button) + { + if (!_rewardButtons.Contains(button)) return; + switch (CustomLinkedRewardSet.LinkedRewardType) + { + case LinkedRewardType.Exclusive: + foreach (var rewardButton in _rewardButtons) + { + var rewardHighlight = NHighlights.Get(rewardButton); + rewardHighlight.AnimShow(); + rewardHighlight.Modulate = rewardButton == button ? NRewardHighlight.gold : NRewardHighlight.red; + } + break; + case LinkedRewardType.Bundled: + foreach (var rewardHighlight in _rewardButtons.Select(rewardButton => NHighlights.Get(rewardButton))) + { + rewardHighlight.AnimShow(); + rewardHighlight.Modulate = NRewardHighlight.gold; + } + + break; + default: + break; + } + + } + + private void OnUnfocused(NRewardButton button) + { + if (!_rewardButtons.Contains(button)) return; + foreach (var rewardButton in _rewardButtons) + NHighlights.Get(rewardButton)?.AnimHide(); + } + private void GetReward(NRewardButton button) { if (CustomLinkedRewardSet.LinkedRewardType == LinkedRewardType.Bundled) { - if (_grabbedReward) return; - _grabbedReward = true; - rewardButtons.Remove(button); - foreach (var rewardButton in rewardButtons) + if (_signalAlreadyReceived) return; + _signalAlreadyReceived = true; + _rewardButtons.Remove(button); + foreach (var rewardButton in _rewardButtons) { rewardButton.GetReward(); } } - //_rewardsScreen.RewardCollectedFrom(this); CustomLinkedRewardSet.OnSkipped(); EmitSignal(RewardClaimedSignalName, this); this.QueueFreeSafely(); } } +/// +/// Currently only subscribes to these. But technically anyone could subscribe to them.
+/// If it turns out others will use these, we could also add the "OnPress" and "OnRelease".
+/// Note that these are invoked after the original method fully ran. +///
+[HarmonyPatch] +public static class NRewardButtonEvents +{ + /// + /// Invoked whenever a NRewardButton gets focused by the player + /// + public static event Action Focused; + /// + /// Invoked whenever a NRewardButton gets unfocused by the player + /// + public static event Action Unfocused; + + [HarmonyPatch(typeof(NRewardButton), "OnFocus")] + [HarmonyPostfix] + private static void OnFocusEvent(NRewardButton __instance) + { + Focused?.Invoke(__instance); + } + [HarmonyPatch(typeof(NRewardButton), "OnUnfocus")] + [HarmonyPostfix] + private static void OnUnfocusEvent(NRewardButton __instance) + { + Unfocused?.Invoke(__instance); + } +} + + [HarmonyPatch] public static class NCustomLinkedRewardSetPatches { diff --git a/BaseLibScenes/NRewardHighlight.cs b/BaseLibScenes/NRewardHighlight.cs new file mode 100644 index 00000000..e749d6b7 --- /dev/null +++ b/BaseLibScenes/NRewardHighlight.cs @@ -0,0 +1,73 @@ +using Godot; + +namespace BaseLib.BaseLibScenes; + +/// +/// Allows highlighting of rewards in the reward screen. Similar to card highlights. +/// +[GlobalClass] +public partial class NRewardHighlight : TextureRect +{ + private static readonly StringName _shaderParameterWidth = new StringName("width"); + private static readonly StringName _shaderParameterEase = new StringName("ease"); + private static readonly StringName _shaderParameterModuloWidth = new StringName("modulo_width"); + private static readonly StringName _shaderParameterRippleSpeed = new StringName("ripple_speed"); + + + + public static readonly Color gold = new Color(1f, 0.784f, 0f, 0.98f); + public static readonly Color red = new Color(0.93f, 0f, 0.13f, 0.98f); + + private Tween? _curTween; + + private ShaderMaterial _shaderMaterial; + + public override void _Ready() + { + _shaderMaterial = new ShaderMaterial(); + _shaderMaterial.Shader = GD.Load("res://shaders/card_ripple.gdshader"); + _shaderMaterial.SetShaderParameter(_shaderParameterEase, 0.005f); + _shaderMaterial.SetShaderParameter(_shaderParameterModuloWidth, 0.02f); + _shaderMaterial.SetShaderParameter(_shaderParameterRippleSpeed, 0.01f); + _shaderMaterial.SetShaderParameter(_shaderParameterWidth, 0.0f); + Material = _shaderMaterial; + } + + public void AnimShow() + { + _curTween?.Kill(); + _curTween = CreateTween(); + _curTween.TweenMethod(Callable.From(SetShaderParameter), GetShaderParameter(), 0.075f, 0.5).SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); + } + + public void AnimHide() + { + _curTween?.Kill(); + _curTween = CreateTween(); + _curTween.TweenMethod(Callable.From(SetShaderParameter), GetShaderParameter(), 0.0, 0.5); + } + + public void AnimHideInstantly() + { + _curTween?.Kill(); + SetShaderParameter(0f); + } + + public void AnimFlash() + { + _curTween?.Kill(); + _curTween = CreateTween(); + _curTween.TweenMethod(Callable.From(SetShaderParameter), GetShaderParameter(), 0.15f, 0.1); + _curTween.TweenMethod(Callable.From(SetShaderParameter), 0.15f, 0.075f, 0.35).SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); + } + + private float GetShaderParameter() + { + return _shaderMaterial.GetShaderParameter(_shaderParameterWidth).AsSingle(); + } + + private void SetShaderParameter(float val) + { + _shaderMaterial.SetShaderParameter(_shaderParameterWidth, val); + } +} From 57211fa784131ed607b4e1680757de1bf189cfb3 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:11:44 +0200 Subject: [PATCH 3/9] Multiplayer support fixed multiplayer synching issues --- BaseLibScenes/NCustomLinkedRewardSet.cs | 4 +- .../LinkedRewardSet}/CustomLinkedRewardSet.cs | 110 ++++++++++++++---- .../ExclusiveLinkedRewardChoiceMessage.cs | 51 ++++++++ .../LinkedRewardSet/LinkedRewardType.cs | 20 ++++ 4 files changed, 160 insertions(+), 25 deletions(-) rename {Abstracts => Common/Rewards/LinkedRewardSet}/CustomLinkedRewardSet.cs (66%) create mode 100644 Common/Rewards/LinkedRewardSet/ExclusiveLinkedRewardChoiceMessage.cs create mode 100644 Common/Rewards/LinkedRewardSet/LinkedRewardType.cs diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs index 1a6d49a2..e4d3c568 100644 --- a/BaseLibScenes/NCustomLinkedRewardSet.cs +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -1,11 +1,13 @@ using System.Reflection; using System.Reflection.Emit; using BaseLib.Abstracts; +using BaseLib.Common.Rewards.LinkedRewardSet; using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Assets; using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Multiplayer.Game; using MegaCrit.Sts2.Core.Nodes.Rewards; using MegaCrit.Sts2.Core.Nodes.Screens; using MegaCrit.Sts2.Core.Rewards; @@ -244,4 +246,4 @@ private static bool CustomLinkedRewardSetCheck(ref Control option, Reward item, option.Connect(NCustomLinkedRewardSet.RewardClaimedSignalName, Callable.From(screen.RewardCollectedFrom)); return true; } -} \ No newline at end of file +} diff --git a/Abstracts/CustomLinkedRewardSet.cs b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs similarity index 66% rename from Abstracts/CustomLinkedRewardSet.cs rename to Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs index e4d5b008..891d4e11 100644 --- a/Abstracts/CustomLinkedRewardSet.cs +++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using BaseLib.Abstracts; using BaseLib.Patches.Content; using BaseLib.Patches.Saves; using BaseLib.Utils; @@ -6,12 +7,13 @@ using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.HoverTips; using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Multiplayer.Game; using MegaCrit.Sts2.Core.Multiplayer.Serialization; using MegaCrit.Sts2.Core.Nodes.Rewards; using MegaCrit.Sts2.Core.Rewards; using MegaCrit.Sts2.Core.Saves.Runs; -namespace BaseLib.Abstracts; +namespace BaseLib.Common.Rewards.LinkedRewardSet; /// /// We do not use BaseGame rewards because they are slightly buggy, and we are providing additional features which is too much work to patch in. @@ -49,6 +51,11 @@ public static bool TryGetCustomLinkedRewardSet(Reward reward, [NotNullWhen(true) private LinkedRewardType _linkedRewardType; public LinkedRewardType LinkedRewardType => _linkedRewardType; + private bool _selectionStarted = false; + + private Reward? _pendingExclusiveSelection; + public void SetPendingExclusiveSelection(Reward chosen) => _pendingExclusiveSelection = chosen; + /// /// Exists only for BaseLib Save logic registration.
@@ -82,9 +89,41 @@ public override void Populate() } - protected override Task OnSelect() + + protected override async Task OnSelect() { - return Task.FromResult(result: true); + if (_selectionStarted) return true; + _selectionStarted = true; + + if (LinkedRewardType == LinkedRewardType.Exclusive) + { + var chosen = _pendingExclusiveSelection; + _pendingExclusiveSelection = null; + if (chosen == null) + { + BaseLibMain.Logger.Error("No Reward selected. Should not happen!"); + return false; + } + + if (!chosen.SuccessfullySelected) + await chosen.SelectUnsynchronized(); + + foreach (var reward in _rewards.ToList()) + { + if (reward != chosen && !reward.SuccessfullySelected) + reward.OnSkipped(); + } + } + if(LinkedRewardType == LinkedRewardType.Bundled) + { + foreach (var reward in _rewards.ToList()) + { + if (reward.SuccessfullySelected) continue; + await reward.SelectUnsynchronized(); // we do only want local machine! + } + } + + return true; } public override void MarkContentAsSeen() @@ -143,27 +182,6 @@ public override void Initialize() } } - -/// -/// Reward gain rules for the Linked Reward -/// -public enum LinkedRewardType -{ - /// - /// Do not use - /// - None, - /// - /// You may only choose 1 option - /// - Exclusive, - /// - /// You either choose All or No options - /// - Bundled -} - - public class SerializableCustomLinkedRewardData : IPacketSerializable { public List Rewards { get; set; } = []; @@ -203,4 +221,48 @@ private static void AddHoverTip(Reward __instance, ref IEnumerable __ list.Add(customLinkedRewardSet.HoverTip); __result = list; } +} + + +[HarmonyPatch] +public static class CustomLinkedRewardSetMultiplayerPatches +{ + [HarmonyPatch(typeof(RewardsSetSynchronizer), nameof(RewardsSetSynchronizer.SelectLocalReward))] + static class RedirectBundledNestedRewardSelection + { + // index must be top level reward, so we reroute to linked container + [HarmonyPrefix] + static bool Prefix(RewardsSetSynchronizer __instance, ref Task __result, ref Reward reward) + { + if (!CustomLinkedRewardSet.TryGetCustomLinkedRewardSet(reward, out var parent)) return true; + + if(parent.LinkedRewardType == LinkedRewardType.Bundled) + { + reward = parent; + return true; + } + // We can not use the existing Synchronisation method for Exclusive Linked rewards. + // The game only sends the index of the reward, no information about anything else. + // Use custom message to bypass base games RewardSetSynchronizer completely in this case. + if (parent.LinkedRewardType == LinkedRewardType.Exclusive) + { + var rewardStateForPlayer = __instance.GetRewardStateForPlayer(__instance.LocalPlayer); + var rewardsSetState = rewardStateForPlayer.rewardsStack.Last(); + var containerIndex = rewardsSetState.set.Rewards.IndexOf(parent); + var nestedIndex = parent.Rewards.ToList().IndexOf(reward); + + parent.SetPendingExclusiveSelection(reward); + __result = __instance.SelectRewardForPlayer(rewardsSetState, parent); // apply locally immediately + + CustomMessageWrapper.Send(new ExclusiveLinkedRewardChoiceMessage + { + setId = rewardsSetState.set.Id, + containerIndex = containerIndex, + nestedIndex = nestedIndex + }); + return false; + } + return true; + } + } } \ No newline at end of file diff --git a/Common/Rewards/LinkedRewardSet/ExclusiveLinkedRewardChoiceMessage.cs b/Common/Rewards/LinkedRewardSet/ExclusiveLinkedRewardChoiceMessage.cs new file mode 100644 index 00000000..d6cf6b19 --- /dev/null +++ b/Common/Rewards/LinkedRewardSet/ExclusiveLinkedRewardChoiceMessage.cs @@ -0,0 +1,51 @@ +using BaseLib.Abstracts; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Multiplayer.Serialization; +using MegaCrit.Sts2.Core.Runs; + +namespace BaseLib.Common.Rewards.LinkedRewardSet; + +public class ExclusiveLinkedRewardChoiceMessage : ICustomMessage +{ + public int setId; + public int containerIndex; + public int nestedIndex; + + public bool ShouldBroadcast => true; + + public void Serialize(PacketWriter writer) + { + writer.WriteInt(setId); + writer.WriteInt(containerIndex, 8); + writer.WriteInt(nestedIndex, 8); + } + + public void Deserialize(PacketReader reader) + { + setId = reader.ReadInt(); + containerIndex = reader.ReadInt(8); + nestedIndex = reader.ReadInt(8); + } + + public void HandleMessage(ulong senderId) + { + if (RunManager.Instance.State is null) + { + BaseLibMain.Logger.Error("RunManager.State is null in a context where it shouldn't be"); + return; + } + var synchronizer = RunManager.Instance.RewardsSetSynchronizer; + var player = RunManager.Instance.State.GetPlayer(senderId); + if (player is null) return; + var rewardStateForPlayer = synchronizer.GetRewardStateForPlayer(player); + var rewardsSetState = rewardStateForPlayer.rewardsStack.Last(); // same assumption the base game itself makes + var set = rewardsSetState.set; + + if (containerIndex < 0 || containerIndex >= set.Rewards.Count) return; + if (set.Rewards[containerIndex] is not CustomLinkedRewardSet container) return; + if (nestedIndex < 0 || nestedIndex >= container.Rewards.Count) return; + + container.SetPendingExclusiveSelection(container.Rewards[nestedIndex]); + TaskHelper.RunSafely(synchronizer.SelectRewardForPlayer(rewardsSetState, container)); + } +} \ No newline at end of file diff --git a/Common/Rewards/LinkedRewardSet/LinkedRewardType.cs b/Common/Rewards/LinkedRewardSet/LinkedRewardType.cs new file mode 100644 index 00000000..3514b21f --- /dev/null +++ b/Common/Rewards/LinkedRewardSet/LinkedRewardType.cs @@ -0,0 +1,20 @@ +namespace BaseLib.Common.Rewards.LinkedRewardSet; + +/// +/// Reward gain rules for the Linked Reward +/// +public enum LinkedRewardType +{ + /// + /// Do not use + /// + None, + /// + /// You may only choose 1 option + /// + Exclusive, + /// + /// You either choose All or No options + /// + Bundled +} From 9ea4952de27b5c6ebabf9432437b13ecacac8c83 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:48:37 +0200 Subject: [PATCH 4/9] small comment changes --- BaseLibScenes/NCustomLinkedRewardSet.cs | 16 +++++------ .../LinkedRewardSet/CustomLinkedRewardSet.cs | 27 ++++++++++--------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs index e4d3c568..e737fe76 100644 --- a/BaseLibScenes/NCustomLinkedRewardSet.cs +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -1,19 +1,19 @@ using System.Reflection; using System.Reflection.Emit; -using BaseLib.Abstracts; using BaseLib.Common.Rewards.LinkedRewardSet; using BaseLib.Utils; using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Assets; using MegaCrit.Sts2.Core.Helpers; -using MegaCrit.Sts2.Core.Multiplayer.Game; using MegaCrit.Sts2.Core.Nodes.Rewards; using MegaCrit.Sts2.Core.Nodes.Screens; using MegaCrit.Sts2.Core.Rewards; namespace BaseLib.BaseLibScenes; +// Our own linked reward node. The scene is identical to the base games linked rewards scene. +// But having our own ensures base game changes to linked rewards will not cause issues. [GlobalClass] public partial class NCustomLinkedRewardSet : Control { @@ -23,8 +23,8 @@ public partial class NCustomLinkedRewardSet : Control public delegate void RewardClaimedEventHandler(NCustomLinkedRewardSet customLinkedRewardSet); /// - /// Similar to only this class uses this. But technically anyone can now make use of the highlighting. - /// Including other modders.
+ /// Similar case to only this class uses this. But technically anyone can now make use of the highlighting. + /// Including other modders.
/// Every NRewardButton gets the highlight node added, not just those used by CustomLinkedRewards. ///
public static AddedNode NHighlights = new((rewardButton) => @@ -204,15 +204,13 @@ private static IEnumerable ReplaceConnectMethodWithCustom(IEnum var nRewardButtonCreate = AccessTools.Method(typeof(NRewardButton), nameof(NRewardButton.Create)); var matcher = new CodeMatcher(instructions) - // Anchor on the unique call that starts the `else` branch: - // option = NRewardButton.Create(item, this); .MatchStartForward(new CodeMatch(OpCodes.Call, nRewardButtonCreate)) .ThrowIfInvalid("Could not find NRewardButton.Create call (else-branch start)"); - // Step back to the else-branch's real first instruction: ldloc.s V_7 - matcher.Advance(-3); + + matcher.Advance(-3); // Step back to the else-branch's real first instruction: ldloc.s V_7 - var optionField = (FieldInfo)matcher.InstructionAt(4).operand; // grab the real field, no guessing + var optionField = (FieldInfo)matcher.InstructionAt(4).operand; // grab the real field var endLabel = matcher.InstructionAt(-1).operand; // the preceding br.s's target = IL_037f // This instruction is the actual jump target for the `if` check (item is LinkedRewardSet == false). diff --git a/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs index 891d4e11..83735443 100644 --- a/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs +++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs @@ -15,8 +15,13 @@ namespace BaseLib.Common.Rewards.LinkedRewardSet; +// We do not use BaseGame rewards because they are slightly buggy, and we are providing additional features which is too much work to patch in. +// May get obsolete once base game fully implements their version of it /// -/// We do not use BaseGame rewards because they are slightly buggy, and we are providing additional features which is too much work to patch in. +/// Custom RewardSet for linked rewards. Supports two ways of resolving:
+/// 1. May choose one or none (Default. Equal to Sts1 behaviour)
+/// 2. Must choose all or none

+/// Do not nest LinkedRewards ///
public class CustomLinkedRewardSet : CustomReward { @@ -74,19 +79,12 @@ public CustomLinkedRewardSet(List rewards, Player player, LinkedRewardTy ParentCustomLinkedRewardSet.Set(reward, this); } - private void RestoreRewards(List rewards, LinkedRewardType linkedRewardType) - { - _rewards = rewards; - _linkedRewardType = linkedRewardType; - foreach (var reward in _rewards) - ParentCustomLinkedRewardSet.Set(reward, this); - } + public override void Populate() { foreach (var reward in _rewards) reward.Populate(); - } @@ -148,12 +146,17 @@ public void RemoveReward(Reward reward) - // Builds an empty placeholder. 'ExtendedSaveHandlers' existing postfix on - // Reward.FromSerializable calls our registered setter right after this returns, - // which is what actually fills in _rewards via RestoreRewards. + public static CustomReward CreateFromSerializable(SerializableReward save, Player player) => new CustomLinkedRewardSet([], player); + private void RestoreRewards(List rewards, LinkedRewardType linkedRewardType) + { + _rewards = rewards; + _linkedRewardType = linkedRewardType; + foreach (var reward in _rewards) + ParentCustomLinkedRewardSet.Set(reward, this); + } public override void Initialize() { From 81794b1c9c70bbfc014329d35780bc0c5cab9b99 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:13:40 +0200 Subject: [PATCH 5/9] Add chains image back in --- BaseLibScenes/NCustomLinkedRewardSet.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs index e737fe76..8d784d8f 100644 --- a/BaseLibScenes/NCustomLinkedRewardSet.cs +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -49,11 +49,12 @@ public partial class NCustomLinkedRewardSet : Control public CustomLinkedRewardSet CustomLinkedRewardSet { get; private set; } + // We are using our own scene because we don't know how/when MegaCrit modifies theirs private static string ScenePath => $"res://BaseLib/scenes/linked_reward_set.tscn"; - + private static string ChainImagePath => ImageHelper.GetImagePath("/ui/reward_screen/reward_chain.png"); // The game groups some assets in AssetSets which exists to not clutter PreloadManager. We aren't using it yet. - // public static IEnumerable AssetPaths => [ScenePath]; + // public static IEnumerable AssetPaths => [ScenePath, ChainImagePath]; private bool _signalAlreadyReceived = false; private readonly List _rewardButtons = []; @@ -106,6 +107,13 @@ private void Reload() nRewardButton.CustomMinimumSize -= Vector2.Right * 20f; _rewardContainer.AddChildSafely(nRewardButton); nRewardButton.Connect(NRewardButton.SignalName.RewardClaimed, Callable.From(GetReward)); + if (i >= CustomLinkedRewardSet.Rewards.Count - 1) continue; + var textureRect = new TextureRect(); + textureRect.MouseFilter = MouseFilterEnum.Ignore; + textureRect.Texture = PreloadManager.Cache.GetCompressedTexture2D(ChainImagePath); + textureRect.Size = Vector2.One * 50f; + _chainsContainer.AddChildSafely(textureRect); + textureRect.GlobalPosition = _chainsContainer.GlobalPosition + Vector2.Down * i * (5f + nRewardButton.CustomMinimumSize.Y); } } From 57edb53ff8b3590760125fc92a446dfa79a79ed0 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:50:48 +0200 Subject: [PATCH 6/9] Small icon adjustment --- BaseLib/localization/eng/static_hover_tips.json | 2 +- BaseLibScenes/NCustomLinkedRewardSet.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json index b3cc7229..11a0304b 100644 --- a/BaseLib/localization/eng/static_hover_tips.json +++ b/BaseLib/localization/eng/static_hover_tips.json @@ -7,7 +7,7 @@ "BASELIB-REFUND.description": "When energy is spent on this card, up to [blue]{Refund}[/blue] of that energy is refunded.", "BASELIB-BUNDLED_REWARDS.title": "Bundled Rewards", - "BASELIB-BUNDLED_REWARDS.description": "You can only select [gold]All[/gold] or [gold]No[/gold] reward from this set.", + "BASELIB-BUNDLED_REWARDS.description": "You can only select [gold]All[/gold] or [gold]No[/gold] rewards from this set.", "BASELIB-EXCLUSIVE_REWARDS.title": "Exclusive Rewards", "BASELIB-EXCLUSIVE_REWARDS.description": "You can only select [blue]1[/blue] reward from this set." } \ No newline at end of file diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs index 8d784d8f..d8ba6a4d 100644 --- a/BaseLibScenes/NCustomLinkedRewardSet.cs +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -113,7 +113,7 @@ private void Reload() textureRect.Texture = PreloadManager.Cache.GetCompressedTexture2D(ChainImagePath); textureRect.Size = Vector2.One * 50f; _chainsContainer.AddChildSafely(textureRect); - textureRect.GlobalPosition = _chainsContainer.GlobalPosition + Vector2.Down * i * (5f + nRewardButton.CustomMinimumSize.Y); + textureRect.GlobalPosition = _chainsContainer.GlobalPosition + Vector2.Down * (-8 + i * (5f + nRewardButton.CustomMinimumSize.Y)); } } From 38e5bfbc5f3e1c2625daaea80ba46276298a26c1 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:32:03 +0200 Subject: [PATCH 7/9] Squashed commit of the following: commit f7db6b5158df441bd46e6fd807b704cd51cffffd Merge: 3adcca2 64acb39 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Fri Jul 17 07:15:25 2026 +0000 Merge pull request #346 from Alchyr/develop 3.3.7 commit 64acb3964823553c5ab28dca7eb5d50edc51a2f2 Author: Alchyr Date: Fri Jul 17 00:14:47 2026 -0700 update comments commit 6993fafddb5c3efae59472a94c31592ba0e647d7 Author: Alchyr Date: Fri Jul 17 00:12:44 2026 -0700 3.3.7 commit bd650491eef9e587e0febc4f0ea0b675789ddf30 Merge: a10a4ab fa17471 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Fri Jul 17 07:10:30 2026 +0000 Merge pull request #339 from hachi-ichi/master Add Japanese localization commit a10a4ab41dbe496d15f72d9923207376296eb070 Author: Alchyr Date: Thu Jul 16 23:51:13 2026 -0700 change auto-formatter registration commit 9ddf1b201206f56f45a3d156d7f83f6684bdadba Author: Alchyr Date: Thu Jul 16 23:45:50 2026 -0700 fix card play destination patches commit 3adcca2b0377eae25c9084e66681a32f6a5e109b Merge: 604a673 0608b6c Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Fri Jul 17 04:50:50 2026 +0000 Merge pull request #345 from Alchyr/develop 3.3.6 commit 0608b6c0891f231687734a29342c6e05d67aa34a Author: Alchyr Date: Thu Jul 16 21:49:47 2026 -0700 3.3.6 commit dfd8ed1b6c0d449914a7c999c4a9efa64e0dda61 Author: Alchyr Date: Thu Jul 16 21:41:19 2026 -0700 beta compatibility commit bc54f420b2fb8828aba274b331c4f0247aada503 Author: Alchyr Date: Thu Jul 16 20:50:56 2026 -0700 wip cost visuals commit 89336bd24fbd7f0053280a68e2bc61d46bedeceb Author: Alchyr Date: Wed Jul 15 13:23:49 2026 -0700 initial custom resource; default UI todo commit b01c11323f3368f2f27170593a51836216168e98 Author: Alchyr Date: Wed Jul 15 13:05:17 2026 -0700 add seen cards to scry hook commit ab50ef41569386255ca7c6184788944f7716739c Merge: b496107 14fda51 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Wed Jul 15 20:01:08 2026 +0000 Merge pull request #330 from lamali292/scry Scry commit b496107c4c35b008a191f48fc43c6ce6449818bb Author: Alchyr Date: Mon Jul 13 22:21:22 2026 -0700 add serializer/deserializers for common array types commit 8faca4bad1b7160d2fcef5487cac362fddeb9e31 Author: Alchyr Date: Mon Jul 13 00:19:30 2026 -0700 move scene conversion registration patch and check applicable types properly commit 223737d63b18f10bd98a1fdbef02bac6326be79e Author: Alchyr Date: Sun Jul 12 16:56:46 2026 -0700 check for ISceneConversions on all mod types commit fa174712589d47be4ca740e98f8dd2144a893316 Author: hachi-ichi Date: Sun Jul 12 07:29:55 2026 +0900 Fix Japanese upgrade selection prompt for multiple cards commit 09ca13c55c4c96726f1fabb003e8c4cabbe1bbdd Author: hachi-ichi Date: Fri Jul 10 21:57:35 2026 +0900 Add Japanese localization commit 14fda5160be0c283b4ef12899afef1032db16098 Author: lamali Date: Wed Jul 8 15:59:53 2026 +0200 revert hook changes out of scope of pr that might change behaviour. commit 10a953790a7f6a9bcd938ea76bb218f89faf13f3 Merge: 2eaa8a5 8343818 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Wed Jul 8 10:02:13 2026 +0000 Merge pull request #335 from BAKAOLC/develop Add NativeFileDialogChrome for improved file dialog handling commit 2eaa8a583b7f08cd9371aa2623c1f0c312a001ad Author: Alchyr Date: Wed Jul 8 03:00:32 2026 -0700 cleanup commit 7fcc1a8632c3e347bcf116f2aab53377567b51e9 Author: Alchyr Date: Wed Jul 8 03:00:18 2026 -0700 cleanup commit 1728b282373b6424c2881a6d3189be6b538ee198 Merge: 3153e8c a2fe687 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Wed Jul 8 09:53:05 2026 +0000 Merge pull request #334 from exscape/modconfig-corruption-fix Mod config corruption and concurrency fixes commit 3153e8c21d0a8f5683180e5322c948c05a37ad40 Author: Alchyr Date: Wed Jul 8 02:48:43 2026 -0700 adjust eng loc commit 5bfe648ca2514f70ede38d647f79284fbd668216 Author: Alchyr Date: Wed Jul 8 02:45:07 2026 -0700 adjust whatmod display text and increase config options commit 1260f15d588bba03c8255f3303323f91e37c2824 Author: Alchyr Date: Tue Jul 7 04:46:36 2026 -0700 move what_mod loc to gameplay_ui.json commit 0e19a29d4351371d1f4f611d14dec934d47656e5 Merge: edfc4af 1902002 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Tue Jul 7 11:42:24 2026 +0000 Merge pull request #313 from 1939323749/feature/mod-source-tooltip Add "from which mod?" source to tooltips commit 834381873a8222310a9e3eb16c02cdaa08eab024 Author: OLC Date: Tue Jul 7 12:31:48 2026 +0800 Add NativeFileDialogChrome for improved file dialog handling - Introduced a new class, NativeFileDialogChrome, to manage file dialog popups with a custom modal layer. - Updated BaseLibConfig to utilize the new dialog handling, allowing for a specified output path for Harmony patch dumps. - Enhanced user experience by ensuring dialogs are properly centered and focus is restored after closing. commit a2fe687a8c7f0a9e2f1af6417756c3d45010e204 Author: Aeluwas Date: Mon Jul 6 21:17:08 2026 +0200 Mod config: improve behavior on broken config If the config is corrupt, don't disable saving; instead, move the broken config (in case the user has edited it manually) and restore defaults. This way, corrupt configs don't require user intervention, while still not nuking the config for e.g. those who edited it and made a syntax error. Helps with fixing #333 for people already affected. commit 3c31f03d1e50aedea0b430e7fe4fd55561a6b130 Author: Aeluwas Date: Mon Jul 6 19:20:38 2026 +0200 Mod config: improve load/save thread safety Respect _configFileLock for all operations that touch the config file: Save, SaveDebounced, Load. Should help prevent mod config corruption. Also locks during save cancellation, to correctly debounce when called from multiple threads; previously, multiple saves could occur. commit 5968f997351ad574ffdcc819d283c17d7df9f9bb Author: Aeluwas Date: Mon Jul 6 17:51:35 2026 +0200 Mod config: improve save reliability Save to a temporary file, and replace the config when successful. This prevents truncating the config in some failure cases (locked file, crash during save, etc). Fixes #333. commit e9bd13dc16ae76cec2eb10af825f29b63da0b437 Author: lamali Date: Sat Jul 4 18:47:56 2026 +0200 Scry commit edfc4af4cc9856a824789683d9195d9b51b381fb Merge: 604a673 877dae6 Author: Alchyr <39511935+Alchyr@users.noreply.github.com> Date: Sat Jul 4 06:37:30 2026 +0000 Merge pull request #274 from BAKAOLC/baselib-health-bar-forecast-sync Optimized various features related to health bar forecast updates commit 1902002ec897fd5a76507a9433687590f1da771d Merge: f9e6c8e 424de34 Author: snowlie <1939323749@qq.com> Date: Thu Jul 2 14:08:09 2026 +0800 Merge branch 'develop' into feature/mod-source-tooltip commit f9e6c8e5ac4acafdde894b840b9c839e229ff5e3 Author: snowlie <1939323749@qq.com> Date: Mon Jun 29 22:41:09 2026 +0800 Add "from which mod?" source to tooltips Shows which mod a piece of content comes from. Folded into the hover tooltip for cards, relics, powers, potions, enchantments, orbs and afflictions; events and Ancient encounters get an on-screen label (the Ancient one tucked under the name banner, appearing only after the intro settles) and enemies get a line under their nameplate. Adds a ShowModSourceTooltip setting, on by default. commit 877dae6be7bd87ca1102b14c3a75428b5224a3b8 Author: OLC Date: Thu Jun 11 12:17:41 2026 +0800 Add health bar forecast features: introduce new properties for left origin layout and exclusive Z group, enhance segment handling, and implement forecast builders for better health bar management. --- Abstracts/CustomResource.cs | 1075 +++++++++++++++++ Abstracts/CustomTemporaryPowerModel.cs | 84 +- BaseLib.csproj | 4 +- BaseLib.json | 2 +- BaseLib/localization/deu/card_keywords.json | 2 +- BaseLib/localization/deu/gameplay_ui.json | 3 +- BaseLib/localization/deu/settings_ui.json | 3 + BaseLib/localization/eng/gameplay_ui.json | 3 +- BaseLib/localization/eng/settings_ui.json | 31 +- BaseLib/localization/jpn/card_keywords.json | 4 + BaseLib/localization/jpn/card_selection.json | 8 + BaseLib/localization/jpn/gameplay_ui.json | 7 + BaseLib/localization/jpn/main_menu_ui.json | 3 + BaseLib/localization/jpn/powers.json | 9 + BaseLib/localization/jpn/settings_ui.json | 54 + .../localization/jpn/static_hover_tips.json | 8 + BaseLib/localization/rus/gameplay_ui.json | 3 + BaseLib/localization/rus/settings_ui.json | 5 + BaseLib/localization/zhs/gameplay_ui.json | 3 +- BaseLib/localization/zhs/settings_ui.json | 2 + Cards/Variables/ScryVar.cs | 43 + Commands/MultiPileCardSelect.cs | 2 +- Commands/ScryCmd.cs | 105 ++ Config/BaseLibConfig.cs | 26 +- Config/ModConfig.cs | 132 +- Config/NativeFileDialogChrome.cs | 106 ++ Extensions/DynamicVarSetExtensions.cs | 13 +- Extensions/StringExtensions.cs | 5 + Hooks/BaseLibHooks.cs | 131 ++ Hooks/CustomResourceHooks.cs | 36 + Hooks/HealthBarForecastRegistry.cs | 49 +- Hooks/HealthBarForecasts.cs | 490 ++++++++ Hooks/IAfterScryed.cs | 54 + Hooks/IHealthBarForecastSource.cs | 81 +- Hooks/IModifyScryAmount.cs | 62 + Notes.txt | 67 + Patches/Features/ExhaustivePatch.cs | 31 +- Patches/Features/ModInteropPatch.cs | 39 +- Patches/Features/PersistPatch.cs | 7 +- Patches/Features/PurgePatch.cs | 32 +- Patches/Hooks/MaxHandSizePatches.cs | 4 +- Patches/Hooks/ModifyHealAmountPatches.cs | 2 +- Patches/Localization/ModelLocPatch.cs | 2 - Patches/PostModInitPatch.cs | 94 +- Patches/UI/AncientSourceLabel.cs | 64 + Patches/UI/CustomResourceUiPatches.cs | 80 ++ Patches/UI/EventSourceLabel.cs | 74 ++ Patches/UI/HealthBarForecastPatch.cs | 393 ++++-- Patches/UI/MerchantCharacterAnimPatch.cs | 30 +- Patches/UI/ModSourceTooltip.cs | 118 ++ Patches/UI/MonsterSourceLabel.cs | 31 + Patches/UI/SceneConversionPatch.cs | 25 - Patches/Utils/SavedSpireFieldPatch.cs | 32 +- Utils/BetaMainCompatibility.cs | 59 +- Utils/DynamicVarSource.cs | 1 - Utils/HookUtils.cs | 298 +++++ Utils/NodeFactories/NodeFactory.cs | 12 +- Utils/SavePatchUtils.cs | 85 ++ Utils/SpireField.cs | 2 +- Utils/WhatMod.cs | 124 ++ 60 files changed, 4026 insertions(+), 328 deletions(-) create mode 100644 Abstracts/CustomResource.cs create mode 100644 BaseLib/localization/jpn/card_keywords.json create mode 100644 BaseLib/localization/jpn/card_selection.json create mode 100644 BaseLib/localization/jpn/gameplay_ui.json create mode 100644 BaseLib/localization/jpn/main_menu_ui.json create mode 100644 BaseLib/localization/jpn/powers.json create mode 100644 BaseLib/localization/jpn/settings_ui.json create mode 100644 BaseLib/localization/jpn/static_hover_tips.json create mode 100644 BaseLib/localization/rus/gameplay_ui.json create mode 100644 Cards/Variables/ScryVar.cs create mode 100644 Commands/ScryCmd.cs create mode 100644 Config/NativeFileDialogChrome.cs create mode 100644 Hooks/BaseLibHooks.cs create mode 100644 Hooks/CustomResourceHooks.cs create mode 100644 Hooks/HealthBarForecasts.cs create mode 100644 Hooks/IAfterScryed.cs create mode 100644 Hooks/IModifyScryAmount.cs create mode 100644 Patches/UI/AncientSourceLabel.cs create mode 100644 Patches/UI/CustomResourceUiPatches.cs create mode 100644 Patches/UI/EventSourceLabel.cs create mode 100644 Patches/UI/ModSourceTooltip.cs create mode 100644 Patches/UI/MonsterSourceLabel.cs create mode 100644 Utils/HookUtils.cs create mode 100644 Utils/WhatMod.cs diff --git a/Abstracts/CustomResource.cs b/Abstracts/CustomResource.cs new file mode 100644 index 00000000..0ec67112 --- /dev/null +++ b/Abstracts/CustomResource.cs @@ -0,0 +1,1075 @@ +using System.Reflection; +using System.Reflection.Emit; +using BaseLib.Extensions; +using BaseLib.Hooks; +using BaseLib.Patches.UI; +using BaseLib.Utils; +using BaseLib.Utils.Patching; +using HarmonyLib; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.Hooks; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Cards; + +namespace BaseLib.Abstracts; + +#region patches + +/// +/// +/// +[HarmonyPatch] +internal static class CustomResourcePatches +{ + internal static readonly List RegisteredResources = []; + + // Patches to prep and clean up custom resources alongside PlayerCombatState. + [HarmonyPatch(typeof(PlayerCombatState), MethodType.Constructor, typeof(Player))] + [HarmonyPostfix] + static void Setup(PlayerCombatState __instance) + { + BaseLibMain.Logger.Debug($"Initializing custom resources ({RegisteredResources.Count}) at start of combat"); + foreach (var resource in RegisteredResources) + { + resource.Prep(__instance); + } + } + + [HarmonyPatch(typeof(PlayerCombatState), nameof(PlayerCombatState.AfterCombatEnd))] + [HarmonyPostfix] + static void Cleanup(PlayerCombatState __instance) + { + BaseLibMain.Logger.Debug($"Cleaning up custom resources ({RegisteredResources.Count}) at end of combat"); + foreach (var resource in RegisteredResources) + { + resource.Cleanup(__instance); + } + } + + // Patches for card cost + [HarmonyPatch(typeof(PlayerCombatState), nameof(PlayerCombatState.HasEnoughResourcesFor))] + [HarmonyPostfix] + static void CheckAdditionalCosts(PlayerCombatState __instance, CardModel card, ref bool __result, ref UnplayableReason reason) + { + //Already false, then skip checking custom costs. + if (!__result) return; + + foreach (var resource in RegisteredResources) + { + var result = resource.ResourceCheck(__instance, card); + if (result == UnplayableReason.None) continue; + + reason = result; + __result = false; + return; + } + } + + // Spend resources + [HarmonyPatch(typeof(CardModel), nameof(CardModel.SpendResources), MethodType.Async)] + [HarmonyTranspiler] + static IEnumerable AddSpendAdditionalCosts(ILGenerator generator, IEnumerable instructions, MethodBase original) + { + return AsyncMethodCall.Create(generator, instructions, original, + AccessTools.Method(typeof(CustomResourcePatches), nameof(SpendAdditionalCosts)), afterState: original); + } + static async Task SpendAdditionalCosts(CardModel __instance) + { + foreach (var resource in RegisteredResources) + { + await resource.Spend(__instance); + } + } + + // Record spent resources + [HarmonyPatch(typeof(CardPlay), nameof(CardPlay.Card), MethodType.Setter)] + [HarmonyPostfix] + static void RecordAdditionalCosts(CardPlay __instance) + { + foreach (var resource in RegisteredResources) + { + resource.RecordSpend(__instance); + } + } + + // Cost modification cleanup + [HarmonyPatch(typeof(CardEnergyCost), nameof(CardEnergyCost.AfterCardPlayedCleanup))] + [HarmonyPostfix] + static void CleanupAdditionalCosts(CardEnergyCost __instance) + { + var card = __instance._card; + foreach (var resource in RegisteredResources) + { + resource.AfterCardPlayedCleanup(card); + } + } + + [HarmonyPatch(typeof(CardModel), nameof(CardModel.EndOfTurnCleanup))] + [HarmonyPostfix] + static void CleanupEndOfTurn(CardModel __instance) + { + foreach (var resource in RegisteredResources) + { + resource.EndOfTurnCleanup(__instance); + } + } + + // Shared cost modification + [HarmonyPatch(typeof(CardModel), nameof(CardModel.SetToFreeThisCombat))] + [HarmonyPostfix] + static void SetToFreeThisCombat(CardModel __instance) + { + foreach (var resource in RegisteredResources) + { + resource.SetToFreeThisCombat(__instance); + } + } + [HarmonyPatch(typeof(CardModel), nameof(CardModel.SetToFreeThisTurn))] + [HarmonyPostfix] + static void SetToFreeThisTurn(CardModel __instance) + { + foreach (var resource in RegisteredResources) + { + resource.SetToFreeThisTurn(__instance); + } + } + + // Upgrade finalize + [HarmonyPatch(typeof(CardModel), nameof(CardModel.FinalizeUpgradeInternal))] + [HarmonyPostfix] + static void FinalizeAdditionalResourceUpgrades(CardModel __instance) + { + foreach (var resource in RegisteredResources) + { + resource.FinalizeUpgrade(__instance); + } + } + + // Downgrade + [HarmonyPatch(typeof(CardModel), nameof(CardModel.DowngradeInternal))] + [HarmonyTranspiler] + static List DowngradeAdditionalResourcesTranspiler(IEnumerable code) + { + return new InstructionPatcher(code) + .Match(new CallMatcher(typeof(CardEnergyCost).Method(nameof(CardEnergyCost.ResetForDowngrade)))) + .Insert([ + CodeInstruction.LoadArgument(0), + CodeInstruction.Call(typeof(CustomResourcePatches), nameof(DowngradeAdditionalResources)) + ]); + } + + static void DowngradeAdditionalResources(CardModel card) + { + foreach (var resource in RegisteredResources) + { + resource.ResetForDowngrade(card); + } + } + + [HarmonyPatch(typeof(CardModel), nameof(CardModel.CostsEnergyOrStars))] + [HarmonyPostfix] + static void OrAnotherResource(CardModel __instance, ref bool __result, bool includeGlobalModifiers) + { + if (__result) return; + + foreach (var resource in RegisteredResources) + { + if (resource.CostsMoreThanZero(__instance, includeGlobalModifiers)) + { + __result = true; + } + } + } +} + + + +#endregion + +internal class ResourceHandler(string id, + Func getResource, + Func getCost, + Action prep, Action cleanup, + Func resourceCheck, + Func spend, + Action recordSpend, + Action afterCardPlayedCleanup, + Action endOfTurnCleanup, + Action setToFreeThisCombat, + Action setToFreeThisTurn, + Action finalizeUpgrade, + Action resetForDowngrade, + Func costsMoreThanZero) : IComparable +{ + public string Id { get; } = id; + + public Func GetResource { get; } = getResource; + public Func GetCost { get; } = getCost; + public Action Prep { get; } = prep; + public Action Cleanup { get; } = cleanup; + + public Func ResourceCheck { get; } = resourceCheck; + + public Func Spend { get; } = spend; + public Action RecordSpend { get; } = recordSpend; + + public Action AfterCardPlayedCleanup { get; } = afterCardPlayedCleanup; + public Action EndOfTurnCleanup { get; } = endOfTurnCleanup; + + public Action SetToFreeThisCombat { get; } = setToFreeThisCombat; + public Action SetToFreeThisTurn { get; } = setToFreeThisTurn; + + public Action FinalizeUpgrade { get; } = finalizeUpgrade; + + public Action ResetForDowngrade { get; } = resetForDowngrade; + + public Func CostsMoreThanZero { get; } = costsMoreThanZero; + + public int CompareTo(ResourceHandler? other) + { + return string.Compare(Id, other?.Id, StringComparison.Ordinal); + } +} + +/// +/// Helper class for storing and accessing information regarding custom costs. +/// +/// The resource type. +public static class CustomResources where T : CustomResource, new() +{ + private static bool _registered; + // Called through reflection in PostModInitPatch for each defined resource type. + internal static void Register(T resourceInstance) + { + if (_registered) return; + _registered = true; + + CustomResourcePatches.RegisteredResources.InsertSorted( + new(resourceInstance.Id, Get, Cost, PrepForCombat, CleanupAfterCombat, ResourceCheck, + Spend, RecordSpend, + AfterCardPlayedCleanup, EndOfTurnCleanup, + SetToFreeThisCombat, SetToFreeThisTurn, + FinalizeUpgrade, ResetForDowngrade, + CostsMoreThanZero)); + } + + private static NotNullSpireField? _resource; + private static SpireField? _canonicalCost; + private static SpireField?>? _cost; + private static SpireField? _lastSpend; + private static SpireField? _recordedSpend; + + private static SpireField CanonicalCostField => + _canonicalCost ??= new SpireField(() => -1) + .CopyOnClone(); + + private static NotNullSpireField Resource => + _resource ??= new NotNullSpireField(() => + { + BaseLibMain.Logger.Debug($"Initializing resource {typeof(T).Name} for combat"); + var res = new T(); + res.PrepForCombat(); + res.AmountChanged += CombatManager.Instance.StateTracker.OnPlayerCombatStateValueChanged; + return res; + }); + + private static SpireField?> CostField => + _cost ??= new SpireField?>(() => null) + .CopyOnClone((source, dest, original) => + { + CostField[dest] = original?.Clone(dest); + }); + + private static SpireField LastSpend => + _lastSpend ??= new SpireField(() => 0); + + private static SpireField RecordedSpend => + _recordedSpend ??= new SpireField(() => 0); + + #region helper methods + + private static void PrepForCombat(PlayerCombatState combatState) + { + Resource.Get(combatState); //Initializes default value of field + } + + private static void CleanupAfterCombat(PlayerCombatState combatState) + { + Resource.Get(combatState).AmountChanged -= CombatManager.Instance.StateTracker.OnPlayerCombatStateValueChanged; + } + + private static UnplayableReason ResourceCheck(PlayerCombatState combatState, CardModel card) + { + var cost = Cost(card); + return cost?.ResourceCheck(combatState, card) ?? UnplayableReason.None; + } + + private static async Task Spend(CardModel card) + { + var cost = Cost(card); + if (cost == null) return; + + await Resource.Get(card.Owner.PlayerCombatState!).Spend(card.CombatState!, card, cost.GetAmountToSpend()); + } + + private static void RecordSpend(CardPlay cardPlay) + { + RecordedSpend[cardPlay] = LastSpend[cardPlay.Card]; + } + + private static void AfterCardPlayedCleanup(CardModel card) + { + Cost(card)?.AfterCardPlayedCleanup(); + } + + private static void EndOfTurnCleanup(CardModel card) + { + if (Cost(card)?.EndOfTurnCleanup() == true) + { + card.InvokeEnergyCostChanged(); + } + } + + private static void SetToFreeThisCombat(CardModel card) + { + var cost = Cost(card); + if (cost != null && Resource.Get(card.Owner.PlayerCombatState!).ApplySharedModification) + { + cost.SetThisCombat(0); + } + } + + private static void SetToFreeThisTurn(CardModel card) + { + var cost = Cost(card); + if (cost != null && Resource.Get(card.Owner.PlayerCombatState!).ApplySharedModification) + { + cost.SetThisTurnOrUntilPlayed(0); + } + } + + private static void FinalizeUpgrade(CardModel card) + { + Cost(card)?.FinalizeUpgrade(); + } + + private static void ResetForDowngrade(CardModel card) + { + Cost(card)?.ResetForDowngrade(); + } + + private static bool CostsMoreThanZero(CardModel card, bool includeGlobalModifiers) + { + var cost = Cost(card); + if (cost == null || cost.CostsX) return false; + + return cost.GetWithModifiers(includeGlobalModifiers ? CostModifiers.All : CostModifiers.Local) > 0; + } + + #endregion + + /// + /// Retrieve the current resource info for a player's combat state. + /// + public static T Get(PlayerCombatState combatState) + { + return Resource[combatState]; + } + + /// + /// Sets a card's canonical cost for this resource. + /// -1 is the default meaning no cost, and is used for X costs. + /// + public static void SetCanonicalCost(CardModel card, int canonicalCost) + { + CanonicalCostField[card] = canonicalCost; + } + /// + /// Sets a card's canonical cost for this resource to be an X cost, represented by . + /// + public static void SetXCost(CardModel card) + { + CanonicalCostField[card] = int.MinValue; + } + + /// + /// The card's canonical cost of this resource. -1 if not set. + /// + public static int CanonicalCost(CardModel card) + { + return CanonicalCostField[card]; + } + + /// + /// Retrieves the object containing the resource's related properties and methods, + /// equivalent to . + /// Null for cards without a canonical cost of this type. + /// + public static CustomResourceCost? Cost(CardModel card) + { + var result = CostField[card]; + if (result != null) return result; + + var canonicalCost = CanonicalCostField[card]; + if (canonicalCost == -1) return null; + + var isXCost = canonicalCost == int.MinValue; + return CostField[card] = new CustomResourceCost(card, isXCost ? 0 : canonicalCost, isXCost); + } + + /// + /// Retrieves the amount of this resource spent on this card play. + /// Default value of 0. + /// + public static int AmountSpent(CardPlay play) + { + return RecordedSpend[play]; + } +} + +/// +/// Base interface for a custom resource for non-generic access +/// +public interface ICustomResourceCost +{ + /// + /// This card's "official" starting resource cost. + /// This is what would appear on the card if it was printed out on paper. + /// + int Canonical { get; } + + /// + /// Whether this card has an resource cost of X. + /// X-costs automatically spend all of the player's remaining resource when played, and their effect should be + /// based on the amount spent. + /// + bool CostsX { get; } + + /// + /// Was this card's resource cost just recently upgraded? + /// This is mainly used to show upgrade preview values in green. + /// This should be cleared after the upgrade is complete. + /// + bool WasJustUpgraded { get; set; } + + /// + /// Does this resource cost have any local modifiers? + /// See for details. + /// + bool HasLocalModifiers { get; } + + /// + /// Get this card's resource cost, including the specified modifier types. + /// See for details on what types are available. + /// + int GetWithModifiers(CostModifiers modifiers); + + /// + /// The amount of this resource most recently spent to play this X-cost card. + /// Used when duplicating X-cost cards, to make sure the duplicates are played with the same value. + /// + /// WARNING: Only use this for calculations related to resources spent. If you're using this to calculate a + /// cost-X card's effect, use instead, as it will take X-value + /// modifications (like ) into account. + /// + public int CapturedXValue { get; set; } + + /// + /// Resolve this cost's X value. Should only be used in on-play logic. + /// Takes modifications to X values (like ) into account. + /// + int ResolveXValue(); + + /// + /// Get the amount of this resource that should be spent to play this card. + /// + /// * For X-cost cards, this is the amount of the resource that its owner has. + /// * For normal cards, this is the current cost including all modifiers + /// (see with ) clamped to 0. + /// + /// The game uses this value when actually spending the resource to play the card. + /// Additionally, this is useful for effects that need to know how much WOULD be spent to play the card without + /// actually playing it, such as . + /// + int GetAmountToSpend(); + + /// + /// Get the "resolved" cost of this card. This can mean one of two things: + /// + /// * For X-cost cards, this is the captured X-cost value (see ). + /// * For normal cards, this is the current cost including all modifiers + /// (see with ) clamped to 0. + /// + /// This is useful for effects that need to know the card's cost AFTER it was played, such as + /// . For normal cards, these effects just care about the card's current cost + /// (including all modifiers). For X-cost cards, these effects care about the X-value that was set for the card when + /// it was played. + /// + int GetResolved(); + + /// + /// Checks if a card should be playable based on this cost. + /// + /// if the card is playable, a different reason otherwise. + UnplayableReason ResourceCheck(PlayerCombatState combatState, CardModel card); + + /// + /// Set this cost to the specified amount until the card is played. + /// + /// + /// says "Reduce the cost of all cards in your Discard Pile to 0 until played." + /// + /// New cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void SetUntilPlayed(int cost, bool reduceOnly = false); + + /// + /// Set this cost to the specified amount until the end of the current turn OR until the card is played, whichever + /// comes first. + /// Note that the text of these effects will just say "this turn"; the "or until played" part is left implicit + /// because it's wordy and rarely relevant. + /// + /// + /// says "Reduce the cost of ALL cards in your Hand to 0 this turn." + /// + /// New cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void SetThisTurnOrUntilPlayed(int cost, bool reduceOnly = false); + + /// + /// BE CAREFUL USING THIS! You usually want instead. + /// Set this cost to the specified amount until the end of the current turn. + /// Note that most effects that say "this turn" really mean "this turn or until played". + /// This method should only be used for the few effects that should last for multiple plays in the same turn. + /// + /// + /// says "This card costs 0 if Osty has attacked this turn." + /// + /// New cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void SetThisTurn(int cost, bool reduceOnly = false); + + /// + /// Set this cost to the specified amount for the rest of the combat. + /// + /// + /// + says "Reduce the cost of ALL cards in your Hand to 1 this combat." + /// + /// New cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void SetThisCombat(int cost, bool reduceOnly = false); + + /// + /// Add the specified amount to this cost until the card is played. + /// + /// + /// says "If this card is in your hand at the end of turn, reduce its cost by 1 + /// until it is played." + /// + /// Amount to add to the cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void AddUntilPlayed(int amount, bool reduceOnly = false); + + /// + /// Add the specified amount to this cost until the end of the current turn OR until the card is played, whichever + /// comes first. + /// Note that the text of these effects will just say "this turn"; the "or until played" part is left implicit + /// because it's wordy and rarely relevant. + /// + /// None yet. Update this if we add one! + /// Amount to add to the cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void AddThisTurnOrUntilPlayed(int amount, bool reduceOnly = false); + + /// + /// BE CAREFUL USING THIS! You usually want instead. + /// Add the specified amount to this cost until the end of the current turn. + /// Note that most effects that say "this turn" really mean "this turn or until played". + /// This method should only be used for the few effects that should last for multiple plays in the same turn. + /// + /// + /// says "Costs 1 less for each Skill played this turn." + /// + /// Amount to add to the cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void AddThisTurn(int amount, bool reduceOnly = false); + + /// + /// Add the specified amount to this cost for the rest of the combat. + /// + /// + /// says "Whenever you draw this card, lower its cost by 1 this combat." + /// + /// Amount to add to the cost. + /// + /// Whether this modifier should only be included in the cost calculation if it would lower the current cost. + /// See for details. + /// + void AddThisCombat(int amount, bool reduceOnly = false); + + /// + /// Clear local cost modifiers that should last until the end of the turn. + /// + /// True if any modifiers were cleared and EnergyCostChanged should be invoked. + bool EndOfTurnCleanup(); + + /// + /// Clear local cost modifiers that should last until the card is played. + /// + /// True if any modifiers were cleared and EnergyCostChanged should be invoked. + bool AfterCardPlayedCleanup(); + + /// + /// Upgrade the cost of this card by the specified amount. + /// + /// Amount to add to the current cost (usually negative). + void UpgradeCostBy(int addend); + + /// + /// Finalize an upgrade after calling UpgradeCostBy. + /// This clears out state that is used for displaying an upgrade preview. + /// + void FinalizeUpgrade(); + + /// Reset cost to base values during downgrade. + void ResetForDowngrade(); + + /// + /// This is mainly meant for internal usage. + /// The base game uses this externally only for . + /// + void SetCustomBaseCost(int newBaseCost); + + // Visuals Methods + + void UpdateCostVisuals(NCard nCard, PileType pileType); +} + +/// +/// A cost for a custom resource, equivalent to . +/// +public class CustomResourceCost : ICustomResourceCost where T : CustomResource, new() +{ + private readonly CardModel _card; + private int _base; + private int _capturedXValue; + + /// + /// This card's local cost modifiers. + /// See for details on how this works. + /// + private List _localModifiers = []; + + /// + public int Canonical { get; } + + /// + public bool CostsX { get; } + + /// + public bool WasJustUpgraded { get; set; } + + /// + public bool HasLocalModifiers => _localModifiers.Count > 0; + + public CustomResourceCost(CardModel card, int canonicalCost, bool costsX) + { + _card = card; + CostsX = costsX; + Canonical = CostsX ? 0 : canonicalCost; + _base = Canonical; + } + + /// + public int GetWithModifiers(CostModifiers modifiers) + { + var withModifiers = _base; + + if (_card.IsCanonical || _base < 0 || CostsX) + return withModifiers; + + if (modifiers.HasFlag(CostModifiers.Local)) + { + foreach (var localModifier in _localModifiers) + withModifiers = localModifier.Modify(withModifiers); + } + + if (modifiers.HasFlag(CostModifiers.Global) && _card.CombatState != null) + withModifiers = (int)Hook.ModifyEnergyCostInCombat(_card.CombatState, _card, withModifiers); + return Math.Max(0, withModifiers); + } + + /// + public int CapturedXValue + { + get + { + if (!CostsX) + throw new InvalidOperationException("Only X-cost cards have a captured value."); + return _capturedXValue; + } + set + { + _card.AssertMutable(); + if (!CostsX) + throw new InvalidOperationException("Only X-cost cards have a captured value."); + _capturedXValue = value; + } + } + + /// + public int ResolveXValue() + { + if (!CostsX) + throw new InvalidOperationException($"This cost of type {GetType()} is not an X-cost."); + return Hook.ModifyXValue(_card.CombatState, _card, CapturedXValue); + } + + /// + public int GetAmountToSpend() + { + if (!CostsX) + return Math.Max(0, GetWithModifiers(CostModifiers.All)); + var playerCombatState = _card.Owner.PlayerCombatState; + return playerCombatState?.Energy ?? 0; + } + + /// + public int GetResolved() + { + return CostsX ? CapturedXValue : Math.Max(0, GetWithModifiers(CostModifiers.All)); + } + + /// + public UnplayableReason ResourceCheck(PlayerCombatState combatState, CardModel card) + { + var resource = CustomResources.Get(combatState); + var required = GetWithModifiers(CostModifiers.All); + return resource.CanAfford(card, required) ? UnplayableReason.None : resource.UnplayableReason; + } + + /// + public void SetUntilPlayed(int cost, bool reduceOnly = false) + { + if (cost == 0 && Canonical < 0) + return; + _localModifiers.Add(new LocalCostModifier(cost, LocalCostType.Absolute, + LocalCostModifierExpiration.WhenPlayed, reduceOnly)); + } + + /// + public void SetThisTurnOrUntilPlayed(int cost, bool reduceOnly = false) + { + if (cost == 0 && Canonical < 0) + return; + _localModifiers.Add(new LocalCostModifier(cost, LocalCostType.Absolute, + LocalCostModifierExpiration.EndOfTurn | LocalCostModifierExpiration.WhenPlayed, reduceOnly)); + } + + /// + public void SetThisTurn(int cost, bool reduceOnly = false) + { + if (cost == 0 && Canonical < 0) + return; + _localModifiers.Add(new LocalCostModifier(cost, LocalCostType.Absolute, + LocalCostModifierExpiration.EndOfTurn, reduceOnly)); + } + + /// + public void SetThisCombat(int cost, bool reduceOnly = false) + { + if (cost == 0 && Canonical < 0) + return; + _localModifiers.Add(new LocalCostModifier(cost, LocalCostType.Absolute, + LocalCostModifierExpiration.EndOfCombat, reduceOnly)); + } + + /// + public void AddUntilPlayed(int amount, bool reduceOnly = false) + { + if (amount == 0) + return; + _localModifiers.Add(new LocalCostModifier(amount, LocalCostType.Relative, + LocalCostModifierExpiration.WhenPlayed, reduceOnly)); + } + + /// + public void AddThisTurnOrUntilPlayed(int amount, bool reduceOnly = false) + { + if (amount == 0) + return; + _localModifiers.Add(new LocalCostModifier(amount, LocalCostType.Relative, + LocalCostModifierExpiration.EndOfTurn | LocalCostModifierExpiration.WhenPlayed, reduceOnly)); + } + + /// + public void AddThisTurn(int amount, bool reduceOnly = false) + { + if (amount == 0) + return; + _localModifiers.Add(new LocalCostModifier(amount, LocalCostType.Relative, + LocalCostModifierExpiration.EndOfTurn, reduceOnly)); + } + + /// + public void AddThisCombat(int amount, bool reduceOnly = false) + { + if (amount == 0) + return; + _localModifiers.Add(new LocalCostModifier(amount, LocalCostType.Relative, + LocalCostModifierExpiration.EndOfCombat, reduceOnly)); + } + + /// + public bool EndOfTurnCleanup() + { + _card.AssertMutable(); + return _localModifiers.RemoveAll(m => + m.Expiration.HasFlag(LocalCostModifierExpiration.EndOfTurn)) > 0; + } + + /// + public bool AfterCardPlayedCleanup() + { + _card.AssertMutable(); + return _localModifiers.RemoveAll(m => + m.Expiration.HasFlag(LocalCostModifierExpiration.WhenPlayed)) > 0; + } + + /// + public void UpgradeCostBy(int addend) + { + _card.AssertMutable(); + if (CostsX || addend == 0) + return; + int num = _base; + int newBaseCost = Math.Max(_base + addend, 0); + WasJustUpgraded = true; + if (newBaseCost < num) + { + foreach (var localModifier in _localModifiers) + { + if (localModifier.Type == LocalCostType.Absolute && localModifier.Amount > newBaseCost) + localModifier.Amount = newBaseCost; + } + } + + SetCustomBaseCost(newBaseCost); + } + + /// + public void FinalizeUpgrade() + { + _card.AssertMutable(); + WasJustUpgraded = false; + } + + /// Reset cost to base values during downgrade. + public void ResetForDowngrade() + { + _card.AssertMutable(); + _base = Canonical; + _card.InvokeEnergyCostChanged(); // Flashes NCard if it exists and updates combat state + } + + /// + public void SetCustomBaseCost(int newBaseCost) + { + _card.AssertMutable(); + _base = newBaseCost; + _card.InvokeEnergyCostChanged(); // Flashes NCard if it exists and updates combat state + } + + /// + /// Create a deep clone of this CustomResourceCost for the specified card. + /// + /// The card that will own the cloned CustomResourceCost. + /// A deep clone of this CustomResourceCost. + public CustomResourceCost Clone(CardModel newCard) + { + var list = _localModifiers + .Select(m => m.Clone()) + .ToList(); + + return new CustomResourceCost(newCard, CustomResources.CanonicalCost(newCard), newCard.EnergyCost.CostsX) + { + _base = _base, + _capturedXValue = _capturedXValue, + WasJustUpgraded = WasJustUpgraded, + _localModifiers = list + }; + } + + // Visuals + + public void UpdateCostVisuals(NCard nCard, PileType pileType) + { + //TODO + } +} + +/// +/// A resource that functions as a cost for cards. +/// Resources do not exist outside of combat; an instance of each resource class is created at the start of each +/// combat attached to each PlayerCombatState. +/// Implementations of this interface should provide a parameterless constructor. +/// An instance of each resource is created during startup for registration using their ID and VisualsHandler properties. +/// +public abstract class CustomResource(string id) +{ + /// + /// Event that should be triggered whenever amount of this resource changes, increase or decrease. + /// By default, all resources will have + /// subscribed to this event. + /// + public event Action? AmountChanged; + + /// + /// A unique ID used to identify and sort this resource type. + /// + public string Id { get; protected set; } = id; + + /// + /// Return new instance of class that will be used as a singleton to receive card cost UI update events. + /// Will be called during startup of game, when the resource is registered. + /// + public abstract ICustomCostVisualsHandler CostVisualsHandler(); + + /// + /// Return new instance of class that will be used as a singleton to receive resource amount UI update events. + /// Will be called during startup of game, when the resource is registered. + /// + public abstract ICustomResourceVisualsHandler ResourceVisualsHandler(); + + /// + /// Whether methods that make a card free to play should also set this cost. + /// + /// + public virtual bool ApplySharedModification => true; + + /// + /// Called when the resource is initialized at the start of each combat, if preparation is necessary. + /// Note that this occurs when the PlayerCombatState is initialized. + /// + public virtual void PrepForCombat() + { + + } + + /// + /// The quantity of this resource available to spend. + /// Can be overridden if custom behavior is necessary (spending something that isn't just a tracked number) + /// + public virtual int Amount + { + get; + set + { + if (value == field) return; + + int oldEnergy = field; + field = value; + AmountChanged?.Invoke(oldEnergy, field); + } + } + + /// + /// Called to spend this resource. Defaults to behaving the same as calling with negative amount, + /// then triggers + /// + /// The model these resources are being spent on. + /// The amount of this resource to spend. + public virtual async Task Spend(ICombatState combatState, AbstractModel? spender, int amount) where T : CustomResource + { + if (this is not T thisT) + throw new ArgumentException( + "Attempted to call Spend on a resource with a generic type that does not match the resource."); + + ModifyAmount(-amount); + + await BaseLibHooks.AfterSpendCustomResource(combatState, thisT, spender, amount); + } + + /// + /// Modifies the quantity of this resource available to spend. + /// + /// The amount to add. + public void ModifyAmount(int change) + { + Amount += change; + } + + /// + /// The UnplayableReason used if you can't afford to play a card due to this resource. + /// + public virtual UnplayableReason UnplayableReason => UnplayableReason.EnergyCostTooHigh; + + /// + /// Return whether you can currently afford to spend this resource on this card. + /// If false, the card will not be playable and will be used. + /// + public virtual bool CanAfford(CardModel card, int cost) + { + return Amount >= cost; + } +} + +/// +/// A basic resource that functions similarly to stars, effectively just being a number tracked per player +/// that can be manipulated and spent. +/// +/// +/// If greater than 0, this resource's amount is set to this value at the start of each turn. +public abstract class BasicCustomResource(string resourceId, int setEachTurn = -1) : CustomResource(resourceId) +{ + private readonly int _setEachTurn = setEachTurn; + + /// + public override void PrepForCombat() + { + Amount = 0; + } + + public override ICustomCostVisualsHandler CostVisualsHandler() + { + return new BasicCostVisualsHandler(this); + } + + public override ICustomResourceVisualsHandler ResourceVisualsHandler() + { + return new BasicResourceVisualsHandler(this); + } +} + +public class BasicCostVisualsHandler(CustomResource resource) : ICustomCostVisualsHandler +{ + +} + +public class BasicResourceVisualsHandler(CustomResource resource) : ICustomResourceVisualsHandler +{ + +} \ No newline at end of file diff --git a/Abstracts/CustomTemporaryPowerModel.cs b/Abstracts/CustomTemporaryPowerModel.cs index 48e1b9c1..b1e67e8e 100644 --- a/Abstracts/CustomTemporaryPowerModel.cs +++ b/Abstracts/CustomTemporaryPowerModel.cs @@ -24,19 +24,32 @@ internal interface IBetaCompatTempPower public abstract class CustomTemporaryPowerModel : CustomPowerModel, ITemporaryPower, IBetaCompatTempPower, IAddDumbVariablesToPowerDescription { private const string LocTurnEndBoolVar = "UntilEndOfOtherSideTurn"; - + + /// public void AddDumbVariablesToPowerDescription(LocString description) { description.Add("TemporaryPowerTitle", this.InternallyAppliedPower.Title); } protected abstract Func ApplyPowerFunc { get; } + + /// public abstract PowerModel InternallyAppliedPower { get; } + + /// public abstract AbstractModel OriginModel { get; } protected virtual bool UntilEndOfOtherSideTurn => false; protected virtual int LastForXExtraTurns => 0; - - public override PowerType Type => InternallyAppliedPower.Type; + + /// + public override PowerType Type => InvertInternalPowerAmount ? + InternallyAppliedPower.Type switch + { + PowerType.Buff => PowerType.Debuff, + PowerType.Debuff => PowerType.Buff, + _ => PowerType.None, + } + : InternallyAppliedPower.Type; public override PowerStackType StackType => PowerStackType.Counter; public override bool AllowNegative => true; @@ -47,16 +60,16 @@ public void AddDumbVariablesToPowerDescription(LocString description) [HarmonyPatch] class OldTemporaryPowerInstancedPatch { - static MethodInfo? TargetMethod = AccessTools.PropertyGetter(typeof(PowerModel), "IsInstanced"); + static MethodInfo? _targetMethod = AccessTools.PropertyGetter(typeof(PowerModel), "IsInstanced"); static IEnumerable TargetMethods() { - if (TargetMethod != null) yield return TargetMethod; + if (_targetMethod != null) yield return _targetMethod; } static bool Prepare() { - return TargetMethod != null; + return _targetMethod != null; } [HarmonyPrefix] @@ -71,8 +84,8 @@ static bool MaybeInstanced(PowerModel __instance, ref bool? __result) [HarmonyPatch] class NewTemporaryPowerInstancedPatch { - private static MethodInfo? GetInstanceType = AccessTools.PropertyGetter(typeof(PowerModel), "InstanceType"); - private static Type? InstanceTypeEnum = "MegaCrit.Sts2.Core.Entities.Powers.PowerInstanceType".TryGetType(); + private static readonly MethodInfo? GetInstanceType = AccessTools.PropertyGetter(typeof(PowerModel), "InstanceType"); + private static readonly Type? InstanceTypeEnum = "MegaCrit.Sts2.Core.Entities.Powers.PowerInstanceType".TryGetType(); static IEnumerable TargetMethods() { if (GetInstanceType != null) yield return GetInstanceType; @@ -104,12 +117,17 @@ static bool MaybeInstanced(PowerModel __instance, ref object? __result) // The whole IgnoreNextInstance thing ONLY exists because of the Misery card // Check Misery.DoHackyThingsForSpecificPowers() for usage + // Removed in beta. private bool _shouldIgnoreNextInstance; + + /// public void IgnoreNextInstance() => _shouldIgnoreNextInstance = true; // Only used for localization purposes + /// protected override IEnumerable CanonicalVars => [new RepeatVar(0), new BoolVar(LocTurnEndBoolVar, false)]; + /// public override async Task BeforeApplied(Creature target, decimal amount, Creature? applier, CardModel? cardSource) { var powerSource = this; @@ -131,7 +149,8 @@ public override async Task BeforeApplied(Creature target, decimal amount, Creatu } } - + + /// public override async Task AfterPowerAmountChanged(PlayerChoiceContext context, PowerModel power, decimal amount, Creature? applier, CardModel? cardSource) { var powerSource = this; @@ -145,6 +164,7 @@ public override async Task AfterPowerAmountChanged(PlayerChoiceContext context, await ApplyPowerFunc(context, powerSource.Owner, InvertInternalPowerAmount ? -amount : amount, applier, cardSource, true); } + /// public override async Task AfterSideTurnEnd(PlayerChoiceContext choiceContext, CombatSide side, IEnumerable participants) { if (participants.Contains(Owner) == UntilEndOfOtherSideTurn) @@ -166,50 +186,4 @@ public override async Task AfterSideTurnEnd(PlayerChoiceContext choiceContext, C await ApplyPowerFunc(choiceContext, Owner, InvertInternalPowerAmount ? Amount : -Amount, Owner, null, true); await PowerCmd.Remove(this); } - - - /*[HarmonyPatch] - class OldAfterTurnEndPatch - { - static MethodInfo? TargetMethod = AccessTools.PropertyGetter(typeof(PowerModel), "AfterTurnEnd"); - - static IEnumerable TargetMethods() - { - if (TargetMethod != null) yield return TargetMethod; - } - - static bool Prepare() - { - return TargetMethod != null; - } - - [HarmonyPrefix] - static bool MaybeInstanced(PowerModel __instance, ref bool? __result) - { - if (__instance is not CustomTemporaryPowerModel tempPower) return true; - - __result = tempPower.LastForXExtraTurns != 0; - return false; - } - } - - public async Task AfterTurnEndOld(PlayerChoiceContext choiceContext, CombatSide side) - { - if (InternallyAppliedPower is CustomTemporaryPowerModel) - { - await PowerCmd.Remove(this); - return; - } - if ((!UntilEndOfOtherSideTurn && side != Owner.Side) || (UntilEndOfOtherSideTurn && side == Owner.Side)) - return; - if (DynamicVars.Repeat.BaseValue > 0) - { - DynamicVars.Repeat.UpgradeValueBy(-1); - return; - } - - Flash(); - await ApplyPowerFunc(choiceContext, Owner, InvertInternalPowerAmount ? Amount : -Amount, Owner, null, true); - await PowerCmd.Remove(this); - }*/ } \ No newline at end of file diff --git a/BaseLib.csproj b/BaseLib.csproj index 3db800c3..51160f19 100644 --- a/BaseLib.csproj +++ b/BaseLib.csproj @@ -21,9 +21,9 @@ Alchyr.Sts2.BaseLib BaseLib-StS2 Mod for Slay the Spire 2 providing utilities and features for other mods. - 3.3.5 + 3.3.7 Alchyr - Restore CardRewardSerializationPatch for main branch, small bugfixes + Japanese loc, fix card play destination changing patches c# $(NoWarn);NU5128;MSB3270 diff --git a/BaseLib.json b/BaseLib.json index 62173103..243ee03b 100644 --- a/BaseLib.json +++ b/BaseLib.json @@ -3,7 +3,7 @@ "name": "BaseLib", "author": "Alchyr", "description": "Modding utility for Slay the Spire 2", - "version": "v3.3.5", + "version": "v3.3.7", "has_pck": true, "has_dll": true, "dependencies": [], diff --git a/BaseLib/localization/deu/card_keywords.json b/BaseLib/localization/deu/card_keywords.json index d43acf11..6821b11f 100644 --- a/BaseLib/localization/deu/card_keywords.json +++ b/BaseLib/localization/deu/card_keywords.json @@ -1,4 +1,4 @@ { "BASELIB-PURGE.title": "Tilgen", - "BASELIB-PURGE.description": "Wird dauerhaft aus dem Kampf und deinem Deck entfernt." + "BASELIB-PURGE.description": "Wird dauerhaft aus dem Kampf und deinem [gold]Deck[/gold] entfernt." } \ No newline at end of file diff --git a/BaseLib/localization/deu/gameplay_ui.json b/BaseLib/localization/deu/gameplay_ui.json index 0741b3fb..d99fbdbb 100644 --- a/BaseLib/localization/deu/gameplay_ui.json +++ b/BaseLib/localization/deu/gameplay_ui.json @@ -3,5 +3,6 @@ "BASELIB-COMBAT_REWARD_CARD_UPGRADE.selectionScreenPrompt": "Wähle eine Karte, die du verbesserst.", "BASELIB-COMBAT_REWARD_RANDOM_CARD_UPGRADE": "Verbessere eine zufällige Karte.", "BASELIB-COMBAT_REWARD_CARD_TRANSFORM": "Transformiere {Upgrade:und verbessere |}{cards:plural:eine Karte|{} Karten}", - "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "Wähle {Amount:plural:eine Karte,|[blue]{}[/blue] Karten,} die du [gold]Transformierst[/gold]{Upgrade: und [gold]Verbesserst[/gold]|}." + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "Wähle {Amount:plural:eine Karte,|[blue]{}[/blue] Karten,} die du [gold]Transformierst[/gold]{Upgrade: und [gold]Verbesserst[/gold]|}.", + "BASELIB-MOD_SOURCE.title": "Aus Mod" } \ No newline at end of file diff --git a/BaseLib/localization/deu/settings_ui.json b/BaseLib/localization/deu/settings_ui.json index 7fefe2e7..a0251d31 100644 --- a/BaseLib/localization/deu/settings_ui.json +++ b/BaseLib/localization/deu/settings_ui.json @@ -14,6 +14,9 @@ "BASELIB-GENERAL_SETTINGS.title": "Allgemeine Einstellungen", + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.title": "Mod-Quelle in Tooltips anzeigen", + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.hover.desc": "Zeigt in einem Tooltip an, aus welchem Mod eine Karte, ein Relikt, eine Kraft oder ein Trank stammt.", + "BASELIB-SFX_PLAYER_LIMIT.title": "Soundeffekt-Obergrenze", "BASELIB-SFX_PLAYER_LIMIT.hover.desc": "Maximale Anzahl an Soundeffekten, die gleichzeitig über BaseLib abgespielt werden können.", diff --git a/BaseLib/localization/eng/gameplay_ui.json b/BaseLib/localization/eng/gameplay_ui.json index 94d29571..c6e0ef64 100644 --- a/BaseLib/localization/eng/gameplay_ui.json +++ b/BaseLib/localization/eng/gameplay_ui.json @@ -3,5 +3,6 @@ "BASELIB-COMBAT_REWARD_CARD_UPGRADE.selectionScreenPrompt": "Choose a card to upgrade.", "BASELIB-COMBAT_REWARD_RANDOM_CARD_UPGRADE": "Upgrade a random card.", "BASELIB-COMBAT_REWARD_CARD_TRANSFORM": "Transform {Upgrade:and Upgrade |}{cards:plural:a card|{} cards}", - "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "Choose {Amount:plural:a card:[blue]{}[/blue] cards} to [gold]Transform[/gold]{Upgrade: and [gold]Upgrade[/gold]|}" + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "Choose {Amount:plural:a card:[blue]{}[/blue] cards} to [gold]Transform[/gold]{Upgrade: and [gold]Upgrade[/gold]|}", + "BASELIB-MOD_SOURCE.title": "WhatMod" } diff --git a/BaseLib/localization/eng/settings_ui.json b/BaseLib/localization/eng/settings_ui.json index c3e3b212..a2c1bac5 100644 --- a/BaseLib/localization/eng/settings_ui.json +++ b/BaseLib/localization/eng/settings_ui.json @@ -13,7 +13,10 @@ "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.desc": "There's always an entry in the game's settings.", "BASELIB-GENERAL_SETTINGS.title": "General Settings", - + + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.title": "Show mod source in tooltips", + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.hover.desc": "Adds a tooltip showing which mod a card, relic, power or potion comes from.", + "BASELIB-SFX_PLAYER_LIMIT.title": "Sound Effect Limit", "BASELIB-SFX_PLAYER_LIMIT.hover.desc": "Maximum number of sound effects that can be played through BaseLib at the same time.", @@ -32,6 +35,32 @@ "BASELIB-LOG_FONT_SIZE.title": "Log font size", "BASELIB-LOG_FONT_SIZE.hover.desc": "Can also be set with Ctrl+scroll in the log window.", "BASELIB-LOG_FONT_SIZE.sliderFormat": "{0:0} px", + + "BASELIB-WHAT_MOD_SECTION.title": "WhatMod", + + "BASELIB-INCLUDE_MOD_ID.title": "Include Mod ID", + "BASELIB-INCLUDE_MOD_ID.hover.desc": "Displays both the mod's name and ID, instead of just its name.", + + "BASELIB-SHOW_CARD_MOD_SOURCE.title": "Cards", + "BASELIB-SHOW_CARD_MOD_SOURCE.hover.desc": "Adds the source mod to the tooltips of cards.", + + "BASELIB-SHOW_RELIC_MOD_SOURCE.title": "Relics", + "BASELIB-SHOW_RELIC_MOD_SOURCE.hover.desc": "Adds the source mod to the tooltips of relics.", + + "BASELIB-SHOW_POTION_MOD_SOURCE.title": "Potions", + "BASELIB-SHOW_POTION_MOD_SOURCE.hover.desc": "Adds the source mod to the tooltips of potions.", + + "BASELIB-SHOW_ANCIENT_MOD_SOURCE.title": "Ancients", + "BASELIB-SHOW_ANCIENT_MOD_SOURCE.hover.desc": "Adds the source mod at the bottom left of ancients.", + + "BASELIB-SHOW_EVENT_MOD_SOURCE.title": "Events", + "BASELIB-SHOW_EVENT_MOD_SOURCE.hover.desc": "Adds the source mod at the bottom left of events.", + + "BASELIB-SHOW_MONSTER_MOD_SOURCE.title": "Monsters", + "BASELIB-SHOW_MONSTER_MOD_SOURCE.hover.desc": "Adds the source mod to the monster's name display.", + + "BASELIB-SHOW_COMBAT_ELEMENT_MOD_SOURCE.title": "Combat Elements", + "BASELIB-SHOW_COMBAT_ELEMENT_MOD_SOURCE.hover.desc": "Adds the source mod to the tooltips of buffs, orbs, enchantments, and afflictions.", "BASELIB-HARMONY_DUMP_SECTION.title": "Harmony patch dump", diff --git a/BaseLib/localization/jpn/card_keywords.json b/BaseLib/localization/jpn/card_keywords.json new file mode 100644 index 00000000..0d6212f3 --- /dev/null +++ b/BaseLib/localization/jpn/card_keywords.json @@ -0,0 +1,4 @@ +{ + "BASELIB-PURGE.title": "パージ", + "BASELIB-PURGE.description": "この戦闘中、およびあなたの[gold]デッキ[/gold]から永久に取り除かれる。" +} diff --git a/BaseLib/localization/jpn/card_selection.json b/BaseLib/localization/jpn/card_selection.json new file mode 100644 index 00000000..34fcded3 --- /dev/null +++ b/BaseLib/localization/jpn/card_selection.json @@ -0,0 +1,8 @@ +{ + "BASELIB-CHOOSE_GENERIC": "{Amount:plural:カードを1枚|カードを[blue]{}[/blue]枚}選択。", + "BASELIB-DRAW_PILE": "山札", + "BASELIB-DISCARD_PILE": "捨て札", + "BASELIB-EXHAUST_PILE": "廃棄札", + "BASELIB-HAND": "手札", + "BASELIB-DECK": "デッキ" +} diff --git a/BaseLib/localization/jpn/gameplay_ui.json b/BaseLib/localization/jpn/gameplay_ui.json new file mode 100644 index 00000000..64a5f3f4 --- /dev/null +++ b/BaseLib/localization/jpn/gameplay_ui.json @@ -0,0 +1,7 @@ +{ + "BASELIB-COMBAT_REWARD_CARD_UPGRADE": "{cards:plural:カード1枚|カード{}枚}をアップグレード。", + "BASELIB-COMBAT_REWARD_CARD_UPGRADE.selectionScreenPrompt": "{Amount:plural:アップグレードするカードを1枚|アップグレードするカードを[blue]{}[/blue]枚}選択。", + "BASELIB-COMBAT_REWARD_RANDOM_CARD_UPGRADE": "ランダムなカード1枚をアップグレード。", + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM": "{cards:plural:カード1枚|カード{}枚}を変化{Upgrade:させ、アップグレードする|させる}。", + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "{Amount:plural:カード1枚:[blue]{}[/blue]枚のカード}を[gold]変化[/gold]{Upgrade:させ、[gold]アップグレード[/gold]する|させる}。" +} diff --git a/BaseLib/localization/jpn/main_menu_ui.json b/BaseLib/localization/jpn/main_menu_ui.json new file mode 100644 index 00000000..7e74029c --- /dev/null +++ b/BaseLib/localization/jpn/main_menu_ui.json @@ -0,0 +1,3 @@ +{ + "BASELIB-MOD_CONFIGURATION": "Mod設定" +} diff --git a/BaseLib/localization/jpn/powers.json b/BaseLib/localization/jpn/powers.json new file mode 100644 index 00000000..f6f9d349 --- /dev/null +++ b/BaseLib/localization/jpn/powers.json @@ -0,0 +1,9 @@ +{ + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.title": "一時的なパワー", + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.description": "敵にパワーを一時的に付与する。", + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.smartDescription": "敵にパワーを一時的に付与する。", + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.description": "一定時間、[gold]パワー[/gold]を増加させる。", + "BASELIB-CUSTOM_TEMPORARY_POWER_MODEL.UP.smartDescription": "[gold]{TemporaryPowerTitle}[/gold]を[blue]{Amount:abs()}[/blue]得る。{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": "[gold]{TemporaryPowerTitle}[/gold]を[blue]{Amount:abs()}[/blue]失う。{UntilEndOfOtherSideTurn:cond:あなたの|敵の}ターン終了時まで持続する。{Repeat:cond:>0?\\n追加で[blue]{Repeat:diff()}[/blue]ターン持続する。|}" +} diff --git a/BaseLib/localization/jpn/settings_ui.json b/BaseLib/localization/jpn/settings_ui.json new file mode 100644 index 00000000..41dfb957 --- /dev/null +++ b/BaseLib/localization/jpn/settings_ui.json @@ -0,0 +1,54 @@ +{ + "BASELIB.mod_title": "BaseLib", + "BASELIB.MOD_CONFIG_SETTINGS_ROW.title": "Mod設定 (BaseLib)", + "BASELIB.MOD_CONFIG_SETTINGS_ROW.button": "設定を開く", + + "BASELIB-UNKNOWN_MOD_NAME": "不明なMod名", + "BASELIB-RESTORE_DEFAULTS_BUTTON.title": "デフォルトに戻す", + "BASELIB-RESTORE_MODCONFIG_CONFIRMATION.header": "復元の確認", + "BASELIB-RESTORE_MODCONFIG_CONFIRMATION.body": "このModの設定をデフォルトに戻しますか?", + + "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.title": "メインメニューにMod設定を表示する(再起動が必要)", + "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.title": "メインメニューにMod設定を表示する", + "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.desc": "ゲームの設定画面には常に項目が表示されます。", + + "BASELIB-GENERAL_SETTINGS.title": "一般設定", + + "BASELIB-SFX_PLAYER_LIMIT.title": "効果音の上限", + "BASELIB-SFX_PLAYER_LIMIT.hover.desc": "BaseLib経由で同時に再生できる効果音の最大数。", + + "BASELIB-LOG_SECTION.title": "ログウィンドウ/コンソール", + + "BASELIB-OPEN_LOG_WINDOW_ON_STARTUP.title": "起動時にログウィンドウを開く", + "BASELIB-OPEN_LOG_WINDOW_ON_STARTUP.hover.desc": "メインメニューの読み込み時にBaseLibのログウィンドウを自動的に開きます。", + + "BASELIB-OPEN_LOG_WINDOW_ON_ERROR.title": "エラー発生時にログウィンドウを開く", + "BASELIB-OPEN_LOG_WINDOW_ON_ERROR.hover.desc": "エラーが発生したときにBaseLibのログウィンドウを自動的に開きます。", + + "BASELIB-LIMITED_LOG_SIZE.title": "ログの保持数", + "BASELIB-LIMITED_LOG_SIZE.hover.desc": "ゲーム内ログウィンドウに保持する最大行数。", + "BASELIB-LIMITED_LOG_SIZE.sliderFormat": "{0:0} 行", + + "BASELIB-LOG_FONT_SIZE.title": "ログのフォントサイズ", + "BASELIB-LOG_FONT_SIZE.hover.desc": "ログウィンドウ内でCtrl+スクロールでも設定できます。", + "BASELIB-LOG_FONT_SIZE.sliderFormat": "{0:0} px", + + "BASELIB-HARMONY_DUMP_SECTION.title": "Harmonyパッチダンプ", + + "BASELIB-HARMONY_PATCH_DUMP_OUTPUT_PATH.title": "出力ファイルパス", + "BASELIB-HARMONY_PATCH_DUMP_OUTPUT_PATH.placeholder": "絶対パスまたは user://… (例: user://baselib_harmony_patch_dump.log)", + "BASELIB-HARMONY_PATCH_DUMP_OUTPUT_PATH.hover.desc": "パッチレポートの書き込み先。「参照」でファイルを選択するか、フルパスまたはGodotのuser://パスを入力してください。", + + "BASELIB-HARMONY_PATCH_DUMP_ON_FIRST_MAIN_MENU.title": "メインメニュー初回読み込み時にダンプ", + "BASELIB-HARMONY_PATCH_DUMP_ON_FIRST_MAIN_MENU.hover.desc": "ゲームセッションごとに1回、メインメニューの読み込み完了後、出力パスが設定されていればレポートを書き出します。", + + "BASELIB-HARMONY_DUMP_BROWSE_FOR_OUTPUT.title": "出力ファイルを選択", + "BASELIB-HARMONY_DUMP_BROWSE.title": "参照…", + "BASELIB-HARMONY_DUMP_BROWSE_FOR_OUTPUT.hover.desc": "保存ダイアログを開き、上の出力パスに反映します。", + + "BASELIB-HARMONY_DUMP_WRITE_NOW.title": "今すぐダンプを書き出す", + "BASELIB-HARMONY_DUMP_NOW.title": "今すぐダンプ", + "BASELIB-HARMONY_DUMP_WRITE_NOW.hover.desc": "現在の出力パスを使ってレポートをすぐに生成します。成功またはエラーはログで確認してください。", + + "BASELIB-HARMONY_DUMP_BROWSE_TITLE.title": "Harmonyパッチダンプを保存" +} diff --git a/BaseLib/localization/jpn/static_hover_tips.json b/BaseLib/localization/jpn/static_hover_tips.json new file mode 100644 index 00000000..a5237c03 --- /dev/null +++ b/BaseLib/localization/jpn/static_hover_tips.json @@ -0,0 +1,8 @@ +{ + "BASELIB-PERSIST.title": "持続", + "BASELIB-PERSIST.description": "各ターン、このカードは{Persist:cond:>1?最初の[blue]{Persist}[/blue]回|最初の1回}使用時に手札に戻る。", + "BASELIB-EXHAUSTIVE.title": "消耗", + "BASELIB-EXHAUSTIVE.description": "このカードは{Exhaustive:cond:>1?[blue]{Exhaustive}[/blue]回|1回}使用すると[gold]廃棄[/gold]される。", + "BASELIB-REFUND.title": "返還", + "BASELIB-REFUND.description": "このカードにエナジーを消費したとき、そのエナジーのうち最大[blue]{Refund}[/blue]が返還される。" +} diff --git a/BaseLib/localization/rus/gameplay_ui.json b/BaseLib/localization/rus/gameplay_ui.json new file mode 100644 index 00000000..2e4091ee --- /dev/null +++ b/BaseLib/localization/rus/gameplay_ui.json @@ -0,0 +1,3 @@ +{ + "BASELIB-MOD_SOURCE.title": "Из мода" +} \ No newline at end of file diff --git a/BaseLib/localization/rus/settings_ui.json b/BaseLib/localization/rus/settings_ui.json index 4579f80b..faff6d86 100644 --- a/BaseLib/localization/rus/settings_ui.json +++ b/BaseLib/localization/rus/settings_ui.json @@ -12,6 +12,11 @@ "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.title": "Отображает кнопку «Конфигурация модов» в главном меню", "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.desc": "Доступ к конфигурации всегда остается в меню настроек игры.", + "BASELIB-GENERAL_SETTINGS.title": "Общие настройки", + + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.title": "Показывать источник мода в подсказках", + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.hover.desc": "Добавляет подсказку, показывающую, из какого мода взяты карта, реликвия, сила или зелье.", + "BASELIB-LOG_SECTION.title": "Окно логов/Консоль", "BASELIB-OPEN_LOG_WINDOW_ON_STARTUP.title": "Открывать окно логов при запуске", diff --git a/BaseLib/localization/zhs/gameplay_ui.json b/BaseLib/localization/zhs/gameplay_ui.json index 46b70fa3..357fcd41 100644 --- a/BaseLib/localization/zhs/gameplay_ui.json +++ b/BaseLib/localization/zhs/gameplay_ui.json @@ -3,5 +3,6 @@ "BASELIB-COMBAT_REWARD_CARD_UPGRADE.selectionScreenPrompt": "选择一张牌来升级。", "BASELIB-COMBAT_REWARD_RANDOM_CARD_UPGRADE": "随机升级一张牌。", "BASELIB-COMBAT_REWARD_CARD_TRANSFORM": "变化{Upgrade:并升级|}{cards:plural:一张牌|{}张牌}。", - "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "选择{Amount:plural:一张牌:[blue]{}[/blue]张牌}来[gold]变化[/gold]{Upgrade:并[gold]升级[/gold]|}" + "BASELIB-COMBAT_REWARD_CARD_TRANSFORM.selectionScreenPrompt": "选择{Amount:plural:一张牌:[blue]{}[/blue]张牌}来[gold]变化[/gold]{Upgrade:并[gold]升级[/gold]|}", + "BASELIB-MOD_SOURCE.title": "来自模组" } diff --git a/BaseLib/localization/zhs/settings_ui.json b/BaseLib/localization/zhs/settings_ui.json index c9e3ab4c..460f2114 100644 --- a/BaseLib/localization/zhs/settings_ui.json +++ b/BaseLib/localization/zhs/settings_ui.json @@ -10,6 +10,8 @@ "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.title": "在主菜单显示模组配置", "BASELIB-SHOW_MOD_CONFIG_IN_MAIN_MENU.hover.desc": "在游戏设置中始终会有一个入口。", "BASELIB-GENERAL_SETTINGS.title": "通用设置", + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.title": "在提示框中显示模组来源", + "BASELIB-SHOW_MOD_SOURCE_TOOLTIP.hover.desc": "在卡牌、遗物、能力或药水的提示框中显示它来自哪个模组。", "BASELIB-SFX_PLAYER_LIMIT.title": "音效数量上限", "BASELIB-SFX_PLAYER_LIMIT.hover.desc": "通过 BaseLib 能同时播放的音效的最大数量。", "BASELIB-LOG_SECTION.title": "日志窗口/控制台", diff --git a/Cards/Variables/ScryVar.cs b/Cards/Variables/ScryVar.cs new file mode 100644 index 00000000..32343354 --- /dev/null +++ b/Cards/Variables/ScryVar.cs @@ -0,0 +1,43 @@ +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 "Scry" keyword, responsible for calculating +/// and updating the scry value shown on card previews, including active combat modifiers. +/// +/// The base scry amount before modifiers are applied. +public class ScryVar(decimal baseValue) : DynamicVar("Scry", baseValue) +{ + /// + /// Updates the preview value of the scry variable on the card. + /// Passes the current integer value through global scry 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 scry counts. + /// + public override void UpdateCardPreview( + CardModel card, + CardPreviewMode previewMode, + Creature? target, + bool runGlobalHooks) + { + var scryAmount = IntValue; + + if (runGlobalHooks) + { + // Passes the value through global listeners (relics, powers, etc.) to get the modified preview total + scryAmount = BaseLibHooks.ModifyScryAmount(card.Owner, scryAmount, out _); + } + + PreviewValue = scryAmount; + } +} \ No newline at end of file diff --git a/Commands/MultiPileCardSelect.cs b/Commands/MultiPileCardSelect.cs index 3df30efb..a4880190 100644 --- a/Commands/MultiPileCardSelect.cs +++ b/Commands/MultiPileCardSelect.cs @@ -98,7 +98,7 @@ public static async Task> Select(PlayerChoiceContext cont result = cards; else { uint choiceId = RunManager.Instance.PlayerChoiceSynchronizer.ReserveChoiceId(player); - await context.SignalPlayerChoiceBegun(PlayerChoiceOptions.None); + await context.SignalPlayerChoiceBegunCompatibility(player, PlayerChoiceOptions.None); if ((bool)AccessTools.Method(typeof(CardSelectCmd), nameof(CardSelectCmd.ShouldSelectLocalCard)).Invoke(null, [player])!) { NPlayerHand.Instance?.CancelAllCardPlay(); NSimpleCardSelectScreen nSimpleCardSelectScreen = NSimpleCardSelectScreen.Create(cards, prefs); diff --git a/Commands/ScryCmd.cs b/Commands/ScryCmd.cs new file mode 100644 index 00000000..ac6b6c15 --- /dev/null +++ b/Commands/ScryCmd.cs @@ -0,0 +1,105 @@ +using BaseLib.Extensions; +using BaseLib.Hooks; +using MegaCrit.Sts2.Core.CardSelection; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Commands; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Hooks; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Commands; + +/// +/// Represents the completed result of a resolved scry action. +/// +public readonly record struct ScryResult +{ + /// + /// Initializes a new instance of the struct with the given discarded cards. + /// + /// The collection of card models chosen to be sent to the discard pile. + public ScryResult(IReadOnlyList discarded) => Discarded = discarded; + + /// + /// Gets the read-only list of cards that were discarded during the scry action. + /// Returns an empty list if no cards were discarded or the scry was aborted. + /// + public IReadOnlyList Discarded => field ?? []; + + /// + /// Gets a default instance representing a skipped or zero-card scry result. + /// + public static ScryResult Empty => default; +} + +/// +/// Command utility responsible for executing the scry mechanic process, including +/// hook modification, player choice grid generation, card movement, and event history logging. +/// +public static class ScryCmd +{ + /// + /// Executes a scry action using the dynamic "Scry" variable value defined on the provided card. + /// + /// The multiplayer choice context tracking current player choices. + /// The card model initiating the scry action. + /// A tracking the execution and resolution details of the scry. + public static Task Execute(PlayerChoiceContext choiceContext, CardModel card) + { + return Execute(choiceContext, card.Owner, card.DynamicVars.Scry().IntValue); + } + + /// + /// Executes a standard scry action by running dynamic modifiers, prompting the player to select + /// cards from the top of their draw pile, routing chosen discards, and firing cleanup hooks. + /// + /// The multiplayer choice context tracking current player choices. + /// The player performing the scry. + /// The base number of cards to reveal before modifiers run. + /// + /// A containing a list of cards the player ultimately chose to discard. + /// Returns if the amount was reduced to zero or if combat is inactive. + /// + public static async Task Execute(PlayerChoiceContext choiceContext, Player player, int amount) + { + var modifiedAmount = BaseLibHooks.ModifyScryAmount(player, amount, out var modifiers); + await BaseLibHooks.AfterModifyingScryAmount(choiceContext, player, modifiers, amount, modifiedAmount); + + if (modifiedAmount <= 0) return default; + + var drawPile = PileType.Draw.GetPile(player); + var discardPile = PileType.Discard.GetPile(player); + var combatState = player.Creature.CombatState; + if (combatState == null) return default; + var cardsToScry = drawPile.Cards.Take(modifiedAmount).ToList(); + if (cardsToScry.Count == 0) return default; + var prefs = new CardSelectorPrefs( + CardSelectorPrefs.DiscardSelectionPrompt, + 0, + cardsToScry.Count + ); + + var cardsToDiscard = (await CardSelectCmd.FromSimpleGrid( + choiceContext, + cardsToScry, + player, + prefs + )).ToList(); + + // we don't want sly, so can't use CardCmd.Discard. + foreach (var card in cardsToDiscard) + { + + await CardPileCmd.Add(card, discardPile); + CombatManager.Instance.History.CardDiscarded(combatState, card); + await Hook.AfterCardDiscarded(combatState, choiceContext, card); + } + discardPile.InvokeContentsChanged(); + + await BaseLibHooks.AfterScryed(choiceContext, player, modifiedAmount, cardsToDiscard.Count, + cardsToScry, cardsToDiscard); + return new ScryResult(cardsToDiscard); + } +} \ No newline at end of file diff --git a/Config/BaseLibConfig.cs b/Config/BaseLibConfig.cs index 748857da..18da3fe5 100644 --- a/Config/BaseLibConfig.cs +++ b/Config/BaseLibConfig.cs @@ -9,6 +9,11 @@ internal class BaseLibConfig : SimpleModConfig // Should likely be at the top, as an easy and obvious opt-out public static bool ShowModConfigInMainMenu { get; set; } = true; + [ConfigSection("GeneralSettings")] + + [ConfigSlider(1, 64)] + public static int SfxPlayerLimit { get; set; } = 16; + [ConfigSection("LogSection")] public static bool OpenLogWindowOnStartup { get; set; } = false; public static bool OpenLogWindowOnError { get; set; } = false; @@ -19,9 +24,15 @@ internal class BaseLibConfig : SimpleModConfig [ConfigSlider(8, 48)] public static int LogFontSize { get; set; } = 14; - [ConfigSection("GeneralSettings")] - [ConfigSlider(1, 64)] - public static int SfxPlayerLimit { get; set; } = 16; + [ConfigSection("WhatModSection")] + public static bool IncludeModId { get; set; } = true; + public static bool ShowCardModSource { get; set; } = false; + public static bool ShowRelicModSource { get; set; } = true; + public static bool ShowPotionModSource { get; set; } = true; + public static bool ShowAncientModSource { get; set; } = true; + public static bool ShowEventModSource { get; set; } = true; + public static bool ShowMonsterModSource { get; set; } = true; + public static bool ShowCombatElementModSource { get; set; } = false; [ConfigSection("HarmonyDumpSection")] [ConfigTextInput(MaxLength = 1024)] @@ -46,6 +57,9 @@ public static void HarmonyDumpBrowseForOutput(ModConfig config) Access = FileDialog.AccessEnum.Filesystem, CurrentFile = "baselib_harmony_patch_dump.log", }; + if (!string.IsNullOrWhiteSpace(HarmonyPatchDumpOutputPath)) + dialog.CurrentPath = HarmonyPatchDumpOutputPath; + dialog.AddFilter("*.log", "Log"); dialog.AddFilter("*.txt", "Text"); @@ -56,10 +70,8 @@ public static void HarmonyDumpBrowseForOutput(ModConfig config) config.ConfigReloaded(); dialog.QueueFree(); }; - dialog.Canceled += dialog.QueueFree; - tree.Root.AddChild(dialog); - dialog.PopupCenteredRatio(0.55f); + NativeFileDialogChrome.Popup(dialog); } [ConfigButton("HarmonyDumpNow")] @@ -78,4 +90,4 @@ public static void HarmonyDumpWriteNow(ModConfig _) [ConfigHideInUI] public static int LogLastPosY { get; set; } = int.MinValue; [ConfigHideInUI] public static string LastModConfigModId { get; set; } = ""; -} \ No newline at end of file +} diff --git a/Config/ModConfig.cs b/Config/ModConfig.cs index 92b30867..b2d5f72f 100644 --- a/Config/ModConfig.cs +++ b/Config/ModConfig.cs @@ -57,10 +57,10 @@ public abstract partial class ModConfig [ConfigIgnore] public string? ModId { get; set; } // Injected by ModConfigRegistry private readonly string _modConfigName; - private bool _savingDisabled; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private CancellationTokenSource? _saveDebounceToken; - private readonly SemaphoreSlim _saveLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _configFileLock = new SemaphoreSlim(1, 1); + private readonly Lock _debounceLock = new(); protected readonly List ConfigProperties = []; private readonly Dictionary _defaultValues = new(); @@ -99,7 +99,6 @@ public ModConfig(string? filename = null) { ModPrefix = GetType().GetPrefix(); ModId = null; - _modConfigName = GetType().FullName ?? "unknown"; var rootNamespace = GetType().GetRootNamespace(); if (string.IsNullOrEmpty(rootNamespace) && string.IsNullOrEmpty(filename)) @@ -113,6 +112,7 @@ public ModConfig(string? filename = null) } var defaultFilename = SpecialCharRegex().Replace(rootNamespace, ""); + _modConfigName = defaultFilename; filename = filename == null ? defaultFilename : SpecialCharRegex().Replace(filename, ""); if (!filename.Contains('.')) filename += ".cfg"; @@ -249,22 +249,31 @@ private async void SaveDebouncedInternal(int delayMs = 1000) { try { - // Cancel the previous request, if any, prior to replacing it - _saveDebounceToken?.Cancel(); - _saveDebounceToken?.Dispose(); + // Cancel the previous request, if any, prior to replacing it; lock to prevent a race condition + // when SaveDebounced is called from multiple threads simultaneously + CancellationToken token; + lock (_debounceLock) + { + _saveDebounceToken?.Cancel(); + _saveDebounceToken = new CancellationTokenSource(); + token = _saveDebounceToken.Token; + } - _saveDebounceToken = new CancellationTokenSource(); - var token = _saveDebounceToken.Token; await Task.Delay(delayMs, token); - await _saveLock.WaitAsync(token); + if (!await _configFileLock.WaitAsync(TimeSpan.FromSeconds(3), token)) + { + BaseLibMain.Logger.Warn($"Timed out waiting for {_modConfigName} save lock in SaveDebounced; skipping save"); + return; + } + try { - Save(); + SaveInternal(); } finally { - _saveLock.Release(); + _configFileLock.Release(); } } catch (OperationCanceledException) @@ -281,19 +290,24 @@ private async void SaveDebouncedInternal(int delayMs = 1000) /// Immediately save the current configuration to disk. Prefer using (on the instance or /// its static variant) instead unless you absolutely must save *now*. Indeed, using SaveDebounced(0) is likely still /// better than calling this directly.
- /// Using both is not recommended and may cause issues with locking/hangs. ///
public void Save() { - if (_savingDisabled) + if (!_configFileLock.Wait(TimeSpan.FromSeconds(3))) { - // No GUI error here, because that would've been shown already when _savingDisabled was set. - ModConfigLogger.Warn($"Skipping save for {_modConfigName} because the config file is currently in a corrupted, read-only state."); + BaseLibMain.Logger.Warn($"Timed out waiting to save config {_modConfigName}."); return; } + try { SaveInternal(); } + finally { _configFileLock.Release(); } + } + + private void SaveInternal() + { Dictionary values = []; + // Convert all the values try { foreach (var property in ConfigProperties) @@ -319,19 +333,54 @@ public void Save() return; } + // Serialize to JSON and write to the config file + // To prevent corruption on write failures, we first write to a temporary file, and if successful, + // replace the config file with the temporary file. + var tempFile = _path + ".new"; try { new FileInfo(_path).Directory?.Create(); - using var fileStream = File.Create(_path); - JsonSerializer.Serialize(fileStream, values, JsonOptions); + + using (var fileStream = File.Create(tempFile)) + { + JsonSerializer.Serialize(fileStream, values, JsonOptions); + } + + File.Move(tempFile, _path, overwrite: true); } catch (Exception e) { ModConfigLogger.Error($"Failed to save config {_modConfigName}: {e.Message}"); } + finally + { + try + { + File.Delete(tempFile); + } + catch { /* ignore */ } + } } public void Load() + { + if (!_configFileLock.Wait(TimeSpan.FromSeconds(3))) + { + BaseLibMain.Logger.Warn($"Timed out waiting to load config {_modConfigName}."); + return; + } + + try + { + LoadInternal(); + } + finally + { + _configFileLock.Release(); + } + } + + private void LoadInternal() { if (!File.Exists(_path)) { @@ -339,12 +388,9 @@ public void Load() return; } - // Missing fields or bad values (safe to overwrite the config if true) + // Missing fields or bad values (will overwrite the config if true) var hasSoftErrors = false; - // Hard errors disable saving (until the next successful load) - _savingDisabled = false; - try { Dictionary? values; @@ -377,18 +423,30 @@ public void Load() ModConfigLogger.Warn($"Loaded config {_modConfigName} with some missing or invalid fields."); } } - catch (JsonException jsonEx) + catch (JsonException) { - // Unlikely to happen except for people who have modified the file manually, so let's be verbose and show in GUI. - var locationText = jsonEx.LineNumber.HasValue - ? $"Line {jsonEx.LineNumber + 1}, position {jsonEx.BytePositionInLine + 1}" - : "unknown line"; - ModConfigLogger.Error($"Failed to parse config file for {_modConfigName}. The JSON is likely invalid.\n" + - $"File path: {_path}\n" + - $"Error location: {locationText}"); - ModConfigLogger.Warn("Config saving has been DISABLED for this mod to protect any manual edits.", true); - _savingDisabled = true; - return; + var fileInfo = new FileInfo(_path); + + if (!fileInfo.Exists || fileInfo.Length == 0) + { + ModConfigLogger.Warn($"Config file {_modConfigName} was missing or empty, likely due to a previous crash. Restoring config to defaults."); + } + else + { + var brokenPath = _path + ".broken"; + try + { + File.Move(_path, brokenPath, overwrite: true); + } + catch { /* ignore */ } + + ModConfigLogger.Error( + $"Failed to parse mod configuration for {_modConfigName}; the file is corrupt.\n" + + $"The broken file was backed up to: {brokenPath}\n" + + $"The mod's configuration has been restored to default."); + } + + hasSoftErrors = true; } catch (Exception e) { @@ -396,11 +454,11 @@ public void Load() return; } - if (hasSoftErrors && !_savingDisabled) - { - ModConfigLogger.Warn($"Saving fresh config for {_modConfigName} to correct soft errors (missing fields, invalid fields)."); - Save(); - } + if (!hasSoftErrors) return; + + // If we got here via Load() we hold _configFileLock, so we can't call Save() here, or it will deadlock + ModConfigLogger.Warn($"Saving fresh config for {_modConfigName} to correct soft errors (missing fields, invalid fields)."); + SaveInternal(); } // Convert a single value and update the property. Return true on success, false on failure. diff --git a/Config/NativeFileDialogChrome.cs b/Config/NativeFileDialogChrome.cs new file mode 100644 index 00000000..0c4cedbb --- /dev/null +++ b/Config/NativeFileDialogChrome.cs @@ -0,0 +1,106 @@ +using Godot; + +namespace BaseLib.Config; + +internal static class NativeFileDialogChrome +{ + private const int FileDialogLayer = 132; + + public static void Popup(FileDialog dialog, float centeredRatio = 0.55f) + { + var tree = Engine.GetMainLoop() as SceneTree; + if (tree?.Root == null) + { + dialog.QueueFree(); + return; + } + + var viewport = tree.Root.GetViewport(); + var previousFocus = viewport?.GuiGetFocusOwner(); + var previousMouseMode = Input.MouseMode; + + var layer = new CanvasLayer + { + Name = "BaseLibNativeFileDialogModal", + Layer = FileDialogLayer, + }; + tree.Root.AddChild(layer); + + var shield = new Control + { + Name = "FileDialogShieldRoot", + MouseFilter = Control.MouseFilterEnum.Stop, + }; + layer.AddChild(shield); + + var dim = new ColorRect + { + Name = "FileDialogDim", + Color = new Color(0f, 0f, 0f, 0.55f), + MouseFilter = Control.MouseFilterEnum.Stop, + }; + dim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect); + shield.AddChild(dim); + + layer.AddChild(dialog); + ConfigureDialog(dialog); + Callable.From(FitShieldToViewport).CallDeferred(); + if (viewport != null) + viewport.SizeChanged += FitShieldToViewport; + + dialog.Canceled += CloseDialog; + dialog.CloseRequested += CloseDialog; + dialog.TreeExiting += RestoreMouseAndFocus; + + Input.MouseMode = Input.MouseModeEnum.Visible; + dialog.PopupCenteredRatio(centeredRatio); + return; + + void FitShieldToViewport() + { + if (!GodotObject.IsInstanceValid(shield) || viewport == null) + return; + + var rect = viewport.GetVisibleRect(); + shield.Position = rect.Position; + shield.Size = rect.Size; + } + + void CloseDialog() + { + if (GodotObject.IsInstanceValid(dialog)) + dialog.QueueFree(); + } + + void RestoreMouseAndFocus() + { + if (GodotObject.IsInstanceValid(viewport)) + viewport.SizeChanged -= FitShieldToViewport; + + Input.MouseMode = previousMouseMode; + + if (GodotObject.IsInstanceValid(layer)) + layer.QueueFree(); + + var target = previousFocus; + if (target == null || !GodotObject.IsInstanceValid(target) || !target.IsVisibleInTree()) + return; + + Callable.From(() => + { + if (GodotObject.IsInstanceValid(target) && target.IsVisibleInTree()) + target.GrabFocus(); + }).CallDeferred(); + } + } + + private static void ConfigureDialog(FileDialog dialog) + { + dialog.Name = "BaseLibNativeFileDialog"; + dialog.Exclusive = true; + dialog.Unresizable = false; + dialog.Transparent = false; + dialog.MinSize = new Vector2I(760, 520); + dialog.Size = dialog.MinSize; + } +} diff --git a/Extensions/DynamicVarSetExtensions.cs b/Extensions/DynamicVarSetExtensions.cs index e58499a2..cc28a648 100644 --- a/Extensions/DynamicVarSetExtensions.cs +++ b/Extensions/DynamicVarSetExtensions.cs @@ -1,4 +1,5 @@ -using MegaCrit.Sts2.Core.Localization.DynamicVars; +using BaseLib.Cards.Variables; +using MegaCrit.Sts2.Core.Localization.DynamicVars; using MegaCrit.Sts2.Core.Models; namespace BaseLib.Extensions; @@ -45,4 +46,14 @@ public T Var(string? name = null) where T : DynamicVar return maybeResult; } } + + + /// + /// Get the Scry var initialized with its default name. + /// + public static ScryVar Scry(this DynamicVarSet vard) + { + return (ScryVar)vard._vars[nameof(Scry)]; + } + } \ No newline at end of file diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs index 4fa30b29..8d56550b 100644 --- a/Extensions/StringExtensions.cs +++ b/Extensions/StringExtensions.cs @@ -74,6 +74,11 @@ public static int ComputeBasicHash(this string s) return hash; } + /// + /// extension method for attempting to retrieve a basegame type by name, mainly used for beta/main branch compatibility. + /// + /// + /// public static Type? TryGetType(this string typeName) { try diff --git a/Hooks/BaseLibHooks.cs b/Hooks/BaseLibHooks.cs new file mode 100644 index 00000000..5d1941f9 --- /dev/null +++ b/Hooks/BaseLibHooks.cs @@ -0,0 +1,131 @@ +using BaseLib.Abstracts; +using BaseLib.Utils; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Models; + + +namespace BaseLib.Hooks; + +/// +/// Central dispatchers for various hooks. Hook interfaces should be implemented on +/// subclasses in the active combat state to be picked up. +/// +public static class BaseLibHooks +{ + /// + /// Dispatches to all subscribed models after a + /// scry has fully resolved (viewed cards chosen, discards moved to the discard pile). + /// + /// Only fires when a scry actually happened: the modified scry amount was greater + /// than zero and the draw pile contained at least one card. Scries that + /// resolve to nothing (amount reduced to 0 by modifiers, or an empty draw pile) + /// never reach this hook. No-op outside combat. + /// + /// + /// Player choice context of the ongoing player decision; each listener is pushed onto it during its invocation. + /// The player who scryed. + /// + /// The scry amount after modifiers, as requested — + /// not clamped to the draw pile size. May exceed the number of cards actually + /// viewed (e.g. Scry 5 with 2 cards in the draw pile passes 5). + /// + /// + /// Number of cards the player chose to discard; always equals the count of + /// . May be 0 when the player kept everything. + /// + /// + /// The cards shown by this scry. + /// + /// + /// The cards discarded by this scry, already added to the discard pile (their per-card + /// discard hooks already dispatched) by the time this hook runs. Empty when the player + /// kept everything. + /// + public static Task AfterScryed(PlayerChoiceContext ctx, Player player, int scryAmount, int discardedAmount, List seen, List discarded) + { + return HookUtils.Dispatch(player.Creature.CombatState, ctx, m => m.AfterScryed(ctx, player, scryAmount, discardedAmount, seen, discarded)); + } + + /// + /// 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. + /// + /// + /// The player about to scry. + /// The base scry amount before modification. + /// + /// The listeners that changed the value, for follow-up dispatch via + /// . + /// + /// + /// The final scry amount. Not clamped: may be zero or negative if listeners reduce it; + /// callers are expected to treat non-positive results as "no scry". + /// + public static int ModifyScryAmount(Player player, int amount, out IEnumerable modifiers) + { + return HookUtils.Modify(player.Creature.CombatState, amount, (m, a) => m.ModifyScryAmount(player, a), out modifiers); + } + + /// + /// Dispatches to each listener + /// that changed the scry 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 + /// . The amounts describe the whole modification pass: + /// every invoked listener receives the same pair, and + /// includes other listeners' changes. + /// + /// + /// Player choice context of the ongoing player decision. + /// The player whose scry amount was modified. + /// The modifier set returned by . + /// The base scry amount before any listener ran. + /// + /// The final amount after all listeners; not clamped, so it may be zero or negative + /// (in which case the scry itself is skipped). + /// + public static Task AfterModifyingScryAmount(PlayerChoiceContext ctx, Player player, IEnumerable modifiers, int originalAmount, int modifiedAmount) + { + return HookUtils.AfterModifying(player.Creature.CombatState!, modifiers, a => a.AfterModifyingScryAmount(ctx, player, originalAmount, modifiedAmount)); + } + + public static async Task AfterSpendCustomResource(ICombatState combatState, T resource, AbstractModel? spender, int amount) where T : CustomResource + { + await HookUtils.Dispatch>(combatState, m => m.AfterSpendResource(combatState, resource, spender, amount)); + } + + /// + /// See . + /// + public static decimal ModifyResourceCostInCombat( + ICombatState combatState, + T resource, + CardModel card, + decimal originalCost) where T : CustomResource + { + if (originalCost < 0M) + return originalCost; + var modifiedCost = HookUtils.Modify, decimal>( + combatState, + originalCost, + (modifier, amt) => modifier.ModifyResourceCostInCombat(card, resource, amt), + out _); + return modifiedCost; + } +} \ No newline at end of file diff --git a/Hooks/CustomResourceHooks.cs b/Hooks/CustomResourceHooks.cs new file mode 100644 index 00000000..c8059422 --- /dev/null +++ b/Hooks/CustomResourceHooks.cs @@ -0,0 +1,36 @@ +using BaseLib.Abstracts; +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Hooks; + +/// +/// Interface for a model that modifies a custom resource cost during combat. +/// Equivalent to basegame . +/// Used by through +/// +public interface IModifyResourceCostInCombat where T : CustomResource +{ + /// + /// Modify the amount of a resource that a card will cost during combat. + /// This will never be called while outside of combat. + /// + /// Card whose cost we're modifying. + /// The resource whose cost is being modified. + /// Original cost. + /// The modified cost. + public decimal ModifyResourceCostInCombat( + CardModel card, + T resource, + decimal originalCost); +} + +/// +/// Interface for a model that responds to a specific custom resource being spent. +/// +/// +/// +public interface IAfterSpendResource where T : CustomResource +{ + Task AfterSpendResource(ICombatState combatState, T resource, AbstractModel? spender, int amount); +} \ No newline at end of file diff --git a/Hooks/HealthBarForecastRegistry.cs b/Hooks/HealthBarForecastRegistry.cs index b28c16a5..c182b554 100644 --- a/Hooks/HealthBarForecastRegistry.cs +++ b/Hooks/HealthBarForecastRegistry.cs @@ -13,7 +13,9 @@ namespace BaseLib.Hooks; /// accepts objects with public instance properties: /// Amount (), Color (), Direction (enum or string /// containing FromLeft/FromRight); optional Order (), OverlayMaterial -/// (), OverlaySelfModulate (?). +/// (), OverlaySelfModulate (?), +/// LeftOriginLayout (enum or string containing Chained/OverlapFromOrigin), +/// LeftExclusiveZGroup (), and AffectsHpLabel (). /// public static class HealthBarForecastRegistry { @@ -232,7 +234,10 @@ private static bool TryConvertForeignSegment(object? segment, out HealthBarForec direction, accessors.ReadOrder(segment), accessors.ReadOverlayMaterial(segment), - accessors.ReadOverlaySelfModulate(segment)); + accessors.ReadOverlaySelfModulate(segment), + ParseLeftOriginLayout(accessors.ReadLeftOriginLayout(segment)), + accessors.ReadLeftExclusiveZGroup(segment), + accessors.ReadAffectsHpLabel(segment)); return true; } @@ -264,6 +269,25 @@ private static bool TryParseDirection(object? directionValue, out HealthBarForec } + private static HealthBarForecastLeftOriginLayout ParseLeftOriginLayout(object? layoutValue) + { + switch (layoutValue) + { + case null: + return HealthBarForecastLeftOriginLayout.Chained; + case HealthBarForecastLeftOriginLayout typedLayout: + return typedLayout; + } + + var layoutName = layoutValue.ToString(); + if (string.IsNullOrWhiteSpace(layoutName)) + return HealthBarForecastLeftOriginLayout.Chained; + + return layoutName.Contains("OverlapFromOrigin", StringComparison.OrdinalIgnoreCase) + ? HealthBarForecastLeftOriginLayout.OverlapFromOrigin + : HealthBarForecastLeftOriginLayout.Chained; + } + private static ForeignSegmentAccessors? CreateForeignSegmentAccessors(Type type) { var amount = type.GetProperty("Amount", BindingFlags.Instance | BindingFlags.Public); @@ -272,6 +296,9 @@ private static bool TryParseDirection(object? directionValue, out HealthBarForec var order = type.GetProperty("Order", BindingFlags.Instance | BindingFlags.Public); var overlayMaterial = type.GetProperty("OverlayMaterial", BindingFlags.Instance | BindingFlags.Public); var overlaySelfModulate = type.GetProperty("OverlaySelfModulate", BindingFlags.Instance | BindingFlags.Public); + var leftOriginLayout = type.GetProperty("LeftOriginLayout", BindingFlags.Instance | BindingFlags.Public); + var leftExclusiveZGroup = type.GetProperty("LeftExclusiveZGroup", BindingFlags.Instance | BindingFlags.Public); + var affectsHpLabel = type.GetProperty("AffectsHpLabel", BindingFlags.Instance | BindingFlags.Public); if (amount?.PropertyType != typeof(int) || color?.PropertyType != typeof(Color) || @@ -295,7 +322,16 @@ private static bool TryParseDirection(object? directionValue, out HealthBarForec ? segment => (int)order.GetValue(segment)! : _ => 0, readOverlay, - readOverlaySelfModulate); + readOverlaySelfModulate, + leftOriginLayout == null + ? _ => null + : segment => leftOriginLayout.GetValue(segment), + leftExclusiveZGroup?.PropertyType == typeof(int) + ? segment => (int)leftExclusiveZGroup.GetValue(segment)! + : _ => 0, + affectsHpLabel?.PropertyType == typeof(bool) + ? segment => (bool)affectsHpLabel.GetValue(segment)! + : _ => true); } /// @@ -320,5 +356,8 @@ private readonly record struct ForeignSegmentAccessors( Func ReadDirection, Func ReadOrder, Func ReadOverlayMaterial, - Func ReadOverlaySelfModulate); -} \ No newline at end of file + Func ReadOverlaySelfModulate, + Func ReadLeftOriginLayout, + Func ReadLeftExclusiveZGroup, + Func ReadAffectsHpLabel); +} diff --git a/Hooks/HealthBarForecasts.cs b/Hooks/HealthBarForecasts.cs new file mode 100644 index 00000000..ff2407ac --- /dev/null +++ b/Hooks/HealthBarForecasts.cs @@ -0,0 +1,490 @@ +using Godot; +using MegaCrit.Sts2.Core.Combat; + +namespace BaseLib.Hooks; + +/// +/// Convenience helpers for building health bar forecast segments. +/// +public static class HealthBarForecasts +{ + /// + /// Starts a general-purpose sequence builder for . + /// + public static HealthBarForecastSequenceBuilder For(HealthBarForecastContext context) + { + return new HealthBarForecastSequenceBuilder(context); + } + + /// + /// Starts a right-growing forecast lane with a fixed . + /// + public static HealthBarForecastLaneBuilder FromRight(HealthBarForecastContext context, Color color) + { + return FromRight(context, color, null); + } + + /// + /// Starts a right-growing forecast lane with a separate optional overlay modulate. + /// + public static HealthBarForecastLaneBuilder FromRight( + HealthBarForecastContext context, + Color color, + Color? overlaySelfModulate) + { + return FromRight(context, color, overlaySelfModulate, true); + } + + /// + public static HealthBarForecastLaneBuilder FromRight( + HealthBarForecastContext context, + Color color, + Color? overlaySelfModulate, + bool affectsHpLabel) + { + return new HealthBarForecastLaneBuilder( + For(context), + color, + HealthBarForecastDirection.FromRight, + overlaySelfModulate, + affectsHpLabel); + } + + /// + /// Starts a left-growing forecast lane with a fixed . + /// + public static HealthBarForecastLaneBuilder FromLeft(HealthBarForecastContext context, Color color) + { + return FromLeft(context, color, null); + } + + /// + public static HealthBarForecastLaneBuilder FromLeft( + HealthBarForecastContext context, + Color color, + Color? overlaySelfModulate) + { + return FromLeft(context, color, overlaySelfModulate, true); + } + + /// + public static HealthBarForecastLaneBuilder FromLeft( + HealthBarForecastContext context, + Color color, + Color? overlaySelfModulate, + bool affectsHpLabel) + { + return new HealthBarForecastLaneBuilder( + For(context), + color, + HealthBarForecastDirection.FromLeft, + overlaySelfModulate, + affectsHpLabel); + } + + /// + /// Returns a single segment when is positive. + /// + public static IEnumerable Single( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial) + { + return Single(amount, color, direction, order, overlayMaterial, null); + } + + /// + /// Returns a single segment when is positive, with optional material and overlay + /// modulate. + /// + public static IEnumerable Single( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate) + { + return Single(amount, color, direction, order, overlayMaterial, overlaySelfModulate, true); + } + + /// + public static IEnumerable Single( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate, + bool affectsHpLabel) + { + if (amount <= 0) + return []; + + return + [ + new HealthBarForecastSegment( + amount, + color, + direction, + order, + overlayMaterial, + overlaySelfModulate, + AffectsHpLabel: affectsHpLabel) + ]; + } + + /// + /// Returns a single segment when is positive, without a custom material. + /// + public static IEnumerable Single( + int amount, + Color color, + HealthBarForecastDirection direction, + int order = 0) + { + return Single(amount, color, direction, order, null, null); + } +} + +/// +/// Mutable builder for one forecast source's ordered segment sequence. +/// +public sealed class HealthBarForecastSequenceBuilder(HealthBarForecastContext context) +{ + private readonly List _segments = []; + + /// + /// Forecast context associated with this sequence. + /// + public HealthBarForecastContext Context { get; } = context; + + /// + /// Appends a segment when is positive. + /// + public HealthBarForecastSequenceBuilder Add( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial) + { + return Add(amount, color, direction, order, overlayMaterial, null); + } + + /// + /// Appends a segment when is positive, with explicit overlay modulate. + /// + public HealthBarForecastSequenceBuilder Add( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate) + { + return Add(amount, color, direction, order, overlayMaterial, overlaySelfModulate, true); + } + + /// + public HealthBarForecastSequenceBuilder Add( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate, + bool affectsHpLabel) + { + if (amount <= 0) + return this; + + var segment = new HealthBarForecastSegment( + amount, + color, + direction, + order, + overlayMaterial, + overlaySelfModulate, + AffectsHpLabel: affectsHpLabel); + if (_segments.Count > 0) + { + var last = _segments[^1]; + if (CanMerge(last, segment)) + { + _segments[^1] = last with { Amount = last.Amount + segment.Amount }; + return this; + } + } + + _segments.Add(segment); + return this; + } + + /// + /// Appends a segment without a custom material. + /// + public HealthBarForecastSequenceBuilder Add( + int amount, + Color color, + HealthBarForecastDirection direction, + int order = 0) + { + return Add(amount, color, direction, order, null, null); + } + + /// + /// Appends all positive amounts as consecutive segments. + /// + public HealthBarForecastSequenceBuilder AddRange( + IEnumerable amounts, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial) + { + return AddRange(amounts, color, direction, order, overlayMaterial, null); + } + + /// + /// Appends all positive amounts as consecutive segments with explicit overlay modulate. + /// + public HealthBarForecastSequenceBuilder AddRange( + IEnumerable amounts, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate) + { + return AddRange(amounts, color, direction, order, overlayMaterial, overlaySelfModulate, true); + } + + /// + public HealthBarForecastSequenceBuilder AddRange( + IEnumerable amounts, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate, + bool affectsHpLabel) + { + ArgumentNullException.ThrowIfNull(amounts); + + foreach (var amount in amounts) + Add(amount, color, direction, order, overlayMaterial, overlaySelfModulate, affectsHpLabel); + + return this; + } + + /// + /// Appends all positive amounts as consecutive segments without a custom material. + /// + public HealthBarForecastSequenceBuilder AddRange( + IEnumerable amounts, + Color color, + HealthBarForecastDirection direction, + int order = 0) + { + return AddRange(amounts, color, direction, order, null, null); + } + + /// + /// Appends segments that trigger at the start of 's turn. + /// + public HealthBarForecastSequenceBuilder AddSideTurnStart( + CombatSide triggerSide, + Color color, + HealthBarForecastDirection direction, + params int[] amounts) + { + return AddRange( + amounts, + color, + direction, + HealthBarForecastOrder.ForSideTurnStart(Context.Creature, triggerSide)); + } + + /// + /// Appends segments that trigger at the end of 's turn. + /// + public HealthBarForecastSequenceBuilder AddSideTurnEnd( + CombatSide triggerSide, + Color color, + HealthBarForecastDirection direction, + params int[] amounts) + { + return AddRange( + amounts, + color, + direction, + HealthBarForecastOrder.ForSideTurnEnd(Context.Creature, triggerSide)); + } + + /// + /// Creates a fixed-color right-growing lane on this sequence. + /// + public HealthBarForecastLaneBuilder FromRight(Color color) + { + return FromRight(color, null); + } + + /// + public HealthBarForecastLaneBuilder FromRight(Color color, Color? overlaySelfModulate) + { + return FromRight(color, overlaySelfModulate, true); + } + + /// + public HealthBarForecastLaneBuilder FromRight(Color color, Color? overlaySelfModulate, bool affectsHpLabel) + { + return new HealthBarForecastLaneBuilder( + this, + color, + HealthBarForecastDirection.FromRight, + overlaySelfModulate, + affectsHpLabel); + } + + /// + /// Creates a fixed-color left-growing lane on this sequence. + /// + public HealthBarForecastLaneBuilder FromLeft(Color color) + { + return FromLeft(color, null); + } + + /// + public HealthBarForecastLaneBuilder FromLeft(Color color, Color? overlaySelfModulate) + { + return FromLeft(color, overlaySelfModulate, true); + } + + /// + public HealthBarForecastLaneBuilder FromLeft(Color color, Color? overlaySelfModulate, bool affectsHpLabel) + { + return new HealthBarForecastLaneBuilder( + this, + color, + HealthBarForecastDirection.FromLeft, + overlaySelfModulate, + affectsHpLabel); + } + + /// + /// Returns the built sequence snapshot. + /// + public IReadOnlyList Build() + { + return _segments.Count == 0 ? [] : _segments.ToArray(); + } + + private static bool CanMerge(HealthBarForecastSegment left, HealthBarForecastSegment right) + { + return left.Color == right.Color && + left.Direction == right.Direction && + left.Order == right.Order && + left.OverlaySelfModulate == right.OverlaySelfModulate && + left.LeftOriginLayout == right.LeftOriginLayout && + left.LeftExclusiveZGroup == right.LeftExclusiveZGroup && + left.AffectsHpLabel == right.AffectsHpLabel && + ReferenceEquals(left.OverlayMaterial, right.OverlayMaterial); + } +} + +/// +/// Convenience wrapper for the common case of one fixed-color forecast lane. +/// +public sealed class HealthBarForecastLaneBuilder( + HealthBarForecastSequenceBuilder sequence, + Color color, + HealthBarForecastDirection direction, + Color? overlaySelfModulate = null, + bool affectsHpLabel = true) +{ + /// + /// Parent sequence builder. + /// + public HealthBarForecastSequenceBuilder Sequence { get; } = sequence; + + /// + /// Appends a segment with explicit and optional . + /// + public HealthBarForecastLaneBuilder Add(int amount, int order, Material? overlayMaterial) + { + Sequence.Add(amount, color, direction, order, overlayMaterial, overlaySelfModulate, affectsHpLabel); + return this; + } + + /// + /// Appends a segment without a custom material. + /// + public HealthBarForecastLaneBuilder Add(int amount, int order = 0) + { + return Add(amount, order, null); + } + + /// + /// Appends multiple segments with the same and optional . + /// + public HealthBarForecastLaneBuilder AddRange(IEnumerable amounts, int order, Material? overlayMaterial) + { + Sequence.AddRange(amounts, color, direction, order, overlayMaterial, overlaySelfModulate, affectsHpLabel); + return this; + } + + /// + /// Appends multiple segments without a custom material. + /// + public HealthBarForecastLaneBuilder AddRange(IEnumerable amounts, int order = 0) + { + return AddRange(amounts, order, null); + } + + /// + /// Appends segments that trigger at the start of 's turn. + /// + public HealthBarForecastLaneBuilder AtSideTurnStart(CombatSide triggerSide, params int[] amounts) + { + var order = HealthBarForecastOrder.ForSideTurnStart(Sequence.Context.Creature, triggerSide); + Sequence.AddRange(amounts, color, direction, order, null, overlaySelfModulate, affectsHpLabel); + return this; + } + + /// + /// Appends segments that trigger at the end of 's turn. + /// + public HealthBarForecastLaneBuilder AtSideTurnEnd(CombatSide triggerSide, params int[] amounts) + { + var order = HealthBarForecastOrder.ForSideTurnEnd(Sequence.Context.Creature, triggerSide); + Sequence.AddRange(amounts, color, direction, order, null, overlaySelfModulate, affectsHpLabel); + return this; + } + + /// + /// Starts another right-growing lane on the same parent sequence. + /// + public HealthBarForecastLaneBuilder ThenFromRight(Color nextColor) + { + return Sequence.FromRight(nextColor, null); + } + + /// + /// Starts another left-growing lane on the same parent sequence. + /// + public HealthBarForecastLaneBuilder ThenFromLeft(Color nextColor) + { + return Sequence.FromLeft(nextColor, null); + } + + /// + /// Returns the built segment snapshot. + /// + public IReadOnlyList Build() + { + return Sequence.Build(); + } +} diff --git a/Hooks/IAfterScryed.cs b/Hooks/IAfterScryed.cs new file mode 100644 index 00000000..4b40ebf0 --- /dev/null +++ b/Hooks/IAfterScryed.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Hooks; + +/// +/// Defines a hook listener that runs automatically after a scry action has fully resolved +/// (the player has viewed the cards and chosen which ones to move to the discard pile). +/// +/// Hook interfaces should be implemented on subclasses in the active +/// combat state to be picked up by the central dispatch pipelines. +/// +/// +public interface IAfterScryed +{ + /// + /// Invoked after a scry action has fully completed, cards have been chosen, and discards have moved. + /// + /// + /// + /// Execution Conditions: This hook only fires when a scry actually happened: the final modified scry + /// amount was greater than zero and the draw pile contained at least one card. Scries that resolve + /// to nothing (amount reduced to 0 by modifiers, or an empty draw pile) never reach this hook. + /// This is a no-op outside of active combat states. + /// + /// + /// State Timing: When this method runs, any cards chosen for discard have already been added to the + /// discard pile, and their individual per-card discard hooks have already finished executing. + /// + /// + /// Player choice context of the ongoing player decision; each listener is pushed onto it during its invocation. + /// The player who scryed. + /// + /// The scry amount after modifiers, as requested. + /// Not clamped to the draw pile size. May exceed the number of cards actually viewed + /// (e.g. Scry 5 with 2 cards in the draw pile passes 5). + /// + /// + /// Number of cards the player chose to discard; always equals the count of . + /// May be 0 when the player kept everything. + /// + /// + /// The cards shown by this scry. + /// + /// + /// The cards discarded by this scry, already added to the discard pile (their per-card discard hooks + /// already dispatched) by the time this hook runs. Empty when the player kept everything. + /// + /// A tracking the asynchronous execution of this follow-up hook logic. + Task AfterScryed(PlayerChoiceContext ctx, Player player, int scryAmount, int discardAmount, List seen, List discarded); +} \ No newline at end of file diff --git a/Hooks/IHealthBarForecastSource.cs b/Hooks/IHealthBarForecastSource.cs index 8fb61c3c..93e6169b 100644 --- a/Hooks/IHealthBarForecastSource.cs +++ b/Hooks/IHealthBarForecastSource.cs @@ -21,6 +21,22 @@ public enum HealthBarForecastDirection FromLeft = 1 } +/// +/// How segments share the empty-edge origin. +/// +public enum HealthBarForecastLeftOriginLayout +{ + /// + /// Segments connect end-to-end from the empty edge. + /// + Chained = 0, + + /// + /// Each segment spans from the empty edge by its own Amount, capped to remaining HP. + /// + OverlapFromOrigin = 1 +} + /// /// One forecast overlay segment for a creature health bar. /// @@ -43,13 +59,27 @@ public enum HealthBarForecastDirection /// used /// for both overlay tint and lethal HP label; when set, is still used for lethal label theming. /// +/// +/// For only: +/// or +/// . +/// +/// +/// For : larger values draw above smaller values. +/// +/// +/// Whether this segment can recolor the HP label when it reaches lethal threshold. +/// public readonly record struct HealthBarForecastSegment( int Amount, Color Color, HealthBarForecastDirection Direction, int Order, Material? OverlayMaterial, - Color? OverlaySelfModulate = null) + Color? OverlaySelfModulate = null, + HealthBarForecastLeftOriginLayout LeftOriginLayout = HealthBarForecastLeftOriginLayout.Chained, + int LeftExclusiveZGroup = 0, + bool AffectsHpLabel = true) { /// /// Initializes a segment without overlay material or separate overlay modulate. @@ -72,6 +102,53 @@ public HealthBarForecastSegment( : this(amount, color, direction, order, overlayMaterial, null) { } + + /// + /// Initializes a segment with an optional overlay modulate and default left-origin layout. + /// + public HealthBarForecastSegment( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate) + : this(amount, color, direction, order, overlayMaterial, overlaySelfModulate, + HealthBarForecastLeftOriginLayout.Chained) + { + } + + /// + /// Initializes a segment with a left-origin layout and default exclusive z group. + /// + public HealthBarForecastSegment( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate, + HealthBarForecastLeftOriginLayout leftOriginLayout) + : this(amount, color, direction, order, overlayMaterial, overlaySelfModulate, leftOriginLayout, 0) + { + } + + /// + /// Initializes a segment with explicit left-origin layout and exclusive z group. + /// + public HealthBarForecastSegment( + int amount, + Color color, + HealthBarForecastDirection direction, + int order, + Material? overlayMaterial, + Color? overlaySelfModulate, + HealthBarForecastLeftOriginLayout leftOriginLayout, + int leftExclusiveZGroup) + : this(amount, color, direction, order, overlayMaterial, overlaySelfModulate, leftOriginLayout, + leftExclusiveZGroup, true) + { + } } /// @@ -137,4 +214,4 @@ public interface IHealthBarForecastSource /// /// Creature and combat context for the bar being drawn. IEnumerable GetHealthBarForecastSegments(HealthBarForecastContext context); -} \ No newline at end of file +} diff --git a/Hooks/IModifyScryAmount.cs b/Hooks/IModifyScryAmount.cs new file mode 100644 index 00000000..b1f66278 --- /dev/null +++ b/Hooks/IModifyScryAmount.cs @@ -0,0 +1,62 @@ +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; + +namespace BaseLib.Hooks; + +/// +/// Hook for models (relics, powers, stances, ...) that adjust the amount of an upcoming scry. +/// Listeners are invoked in hook-listener order via +/// before the scry resolves. +/// +public interface IModifyScryAmount +{ + /// + /// Returns the adjusted scry amount. Called once per pending scry, receiving the value as + /// modified by any earlier listeners. + /// + /// 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: reducing the amount to zero or below cancels the scry + /// entirely (no cards viewed, no dispatch). 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 player about to scry. + /// The scry amount so far, including earlier listeners' changes. + /// The new scry amount; may be lower, higher, or unchanged. + int ModifyScryAmount(Player player, int amount); + + /// + /// Follow-up invoked after all listeners have run, but only on listeners whose + /// changed the value they received. Use this for the + /// side effects of having modified the scry: visuals, sounds, consuming charges, + /// decrementing counters. + /// + /// Runs before any cards are viewed, and runs even when the final amount ended up + /// equal to the original (canceling modifications still count) or non-positive (the + /// scry itself will then be skipped) - do not assume a scry follows. + /// + /// + /// 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 + /// itself. + /// + /// + /// Player choice context of the ongoing player decision. + /// The player whose scry amount was modified. + /// The base scry amount before any listener ran. + /// + /// The final amount after all listeners; not clamped, so it may be zero or negative + /// (in which case the scry is skipped). Equal amounts do not imply this listener did + /// nothing - see the canceling-modifications note above. + /// + Task AfterModifyingScryAmount(PlayerChoiceContext ctx, Player player, int originalAmount, int modifiedAmount); +} \ No newline at end of file diff --git a/Notes.txt b/Notes.txt index a08741a8..3f1acc06 100644 --- a/Notes.txt +++ b/Notes.txt @@ -1,6 +1,73 @@ https://github.com/pawslee/il-guide actually a very nice introduction +CUSTOM RESOURCES + +NCard UpdateEnergyCostVisuals (check _pretendCardCanBePlayed) +x PlayerCombatState.HasEnoughResourcesFor + + +x CardModel FinalizeUpgradeInternal, EndOfTurnCleanup, DowngradeInternal, CostsEnergyOrStars +x CardModel.OnPlayWrapper => EnergyCost.AfterCardPlayedCleanup; just patch AfterCardPlayedCleanup of CardEnergyCost to also clear other costs + + +GetResourceCostColor (see CardCostHelper) +virtual method overridable in custom resource + + +Annoying-ish: +x CardModel.SpendResources return value, store attached to resource info thing +x Vakuu Whispering Earrings - calls SpendResources but doesn't care about return value. Can probably somewhat ignore? +These resources *should* be passed to the CardPlay. +Vakuu does *not* set this properly. Maybe can be considered a bug? + + +Cost modification effects: +x Costs have bool - IncludeCombinedModifications (if cost should be changed by stuff like mummified hand that sets all costs to 0) +default true + +x CardModel SetToFreeThisCombat/SetToFreeThisTurn +Mummified hand + + + +minor: +chem x flash + +Optional: +NetFullCombatState sync checking +Note star cost isn't checked + + + +Hooks: + +x TryModifyResourceCostWithHooks (see CardCostHelper) + + +FOR THE RESOURCE: + +x integer that is tracked + +ways to gain/lose (see PlayerCmd), note these are (can be) separate from "spending" +(by default spend does same thing as lose) + + +optional: NetFullCombatState.PlayerState resource amount + +Hooks: +x AfterResourceSpent + + +UI: + + + + + + + + TODO auto-scale compendium options for custom pools diff --git a/Patches/Features/ExhaustivePatch.cs b/Patches/Features/ExhaustivePatch.cs index 2d6d238a..de054037 100644 --- a/Patches/Features/ExhaustivePatch.cs +++ b/Patches/Features/ExhaustivePatch.cs @@ -1,5 +1,6 @@ using System.Reflection; using BaseLib.Cards.Variables; +using BaseLib.Utils.Patching; using HarmonyLib; using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Models; @@ -22,7 +23,9 @@ static IEnumerable TargetMethods() static bool Prepare() { - return TargetMethod != null; + if (TargetMethod != null) return true; + BaseLibMain.Logger.Info("No valid target found, skipping old ExhaustivePatch"); + return false; } [HarmonyPostfix] @@ -39,7 +42,8 @@ static void ExhaustForExhaustive(CardModel __instance, ref PileType __result) static class BetaExhaustivePatch { private static MethodInfo? TargetMethod = - AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay"); + AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay") + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultLocationForCardPlay"); static IEnumerable TargetMethods() { @@ -48,16 +52,25 @@ static IEnumerable TargetMethods() static bool Prepare() { - return TargetMethod != null; + if (TargetMethod != null) return true; + BaseLibMain.Logger.Info("No valid target found, skipping beta ExhaustivePatch"); + return false; } - [HarmonyPostfix] - static void ExhaustForExhaustive(CardModel __instance, ref (PileType, CardPilePosition) __result) + [HarmonyTranspiler] + static List ExhaustForExhaustive(IEnumerable code) { - if (ShouldExhaustForExhaustive(__instance)) - { - __result = (PileType.Exhaust, CardPilePosition.Bottom); - } + return new InstructionPatcher(code) + .Match(new CallMatcher(typeof(CardModel).PropertyGetter(nameof(CardModel.ExhaustOnNextPlay)))) + .Insert([ + CodeInstruction.LoadArgument(0), + CodeInstruction.Call(typeof(BetaExhaustivePatch), nameof(AlterResult)) + ]); + } + + private static bool AlterResult(bool origIsExhaustNextUse, CardModel card) + { + return origIsExhaustNextUse || ShouldExhaustForExhaustive(card); } } diff --git a/Patches/Features/ModInteropPatch.cs b/Patches/Features/ModInteropPatch.cs index 1a9730b7..18017a7c 100644 --- a/Patches/Features/ModInteropPatch.cs +++ b/Patches/Features/ModInteropPatch.cs @@ -1,7 +1,9 @@ -using System.Reflection; +using System.Collections.ObjectModel; +using System.Reflection; using System.Reflection.Emit; using System.Runtime.CompilerServices; using BaseLib.Extensions; +using BaseLib.Utils; using BaseLib.Utils.ModInterop; using BaseLib.Utils.Patching; using HarmonyLib; @@ -15,43 +17,14 @@ internal class ModInterop private static readonly BindingFlags ValidMemberFlags = BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance; private static readonly FieldInfo WrappedValueField = AccessTools.DeclaredField(typeof(InteropClassWrapper), nameof(InteropClassWrapper.Value)); - private static readonly FieldInfo? AssemblyField = AccessTools.DeclaredField(typeof(Mod), "assembly"); - private static readonly FieldInfo? AssembliesField = AccessTools.DeclaredField(typeof(Mod), "assemblies"); - private readonly Dictionary> _loadedIds; internal ModInterop() { BaseLibMain.Logger.Info("Generating interop methods and properties"); - //mod.assembly OR mod.assemblies - _loadedIds = []; - ModManager.GetLoadedMods() - .Where(mod => mod.manifest is not null) - .Do(CheckAssembly); - } - - private void CheckAssembly(Mod mod) - { - if (AssemblyField != null) - { - var assembly = (Assembly?) AssemblyField.GetValue(mod); - if (assembly != null) - { - _loadedIds[mod.manifest?.id ?? ""] = [assembly]; - } - } - else if (AssembliesField != null) - { - var assemblies = (List?) AssembliesField.GetValue(mod); - if (assemblies != null) - { - _loadedIds[mod.manifest?.id ?? ""] = [..assemblies]; - } - } - else - { - BaseLibMain.Logger.Info("Unable to find assemblies tied to mods."); - } + _loadedIds = ModManager.GetLoadedMods() + .Where(mod => mod.manifest?.id != null) + .ToDictionary(mod => mod.manifest?.id ?? "", WhatMod.AssembliesForMod); } internal void ProcessType(Harmony harmony, Type t) diff --git a/Patches/Features/PersistPatch.cs b/Patches/Features/PersistPatch.cs index c849f1b9..0694005b 100644 --- a/Patches/Features/PersistPatch.cs +++ b/Patches/Features/PersistPatch.cs @@ -15,7 +15,8 @@ public static class PersistPatch static MethodInfo? TargetMethod = AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeForCardPlay") ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileType") - ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay"); + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay") + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultLocationForCardPlay"); static IEnumerable TargetMethods() { @@ -24,7 +25,9 @@ static IEnumerable TargetMethods() static bool Prepare() { - return TargetMethod != null; + if (TargetMethod != null) return true; + BaseLibMain.Logger.Info("No valid target found, skipping PersistPatch"); + return false; } [HarmonyTranspiler] diff --git a/Patches/Features/PurgePatch.cs b/Patches/Features/PurgePatch.cs index d7fbe74b..b5b9ddbb 100644 --- a/Patches/Features/PurgePatch.cs +++ b/Patches/Features/PurgePatch.cs @@ -1,5 +1,6 @@ using System.Reflection; using BaseLib.Cards; +using BaseLib.Utils.Patching; using HarmonyLib; using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Models; @@ -22,7 +23,9 @@ static IEnumerable TargetMethods() static bool Prepare() { - return TargetMethod != null; + if (TargetMethod != null) return true; + BaseLibMain.Logger.Info("No valid target found, skipping old PurgePatch"); + return false; } [HarmonyPrefix] @@ -42,7 +45,8 @@ static bool GoAwayForever(CardModel __instance, ref PileType __result) static class BetaPurgePatch { private static MethodInfo? TargetMethod = - AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay"); + AccessTools.DeclaredMethod(typeof(CardModel), "GetResultPileTypeAndPositionForCardPlay") + ?? AccessTools.DeclaredMethod(typeof(CardModel), "GetResultLocationForCardPlay"); static IEnumerable TargetMethods() { @@ -51,19 +55,25 @@ static IEnumerable TargetMethods() static bool Prepare() { - return TargetMethod != null; + if (TargetMethod != null) return true; + BaseLibMain.Logger.Info("No valid target found, skipping beta PurgePatch"); + return false; } - [HarmonyPrefix] - static bool GoAwayForever(CardModel __instance, ref (PileType, CardPilePosition) __result) + [HarmonyTranspiler] + static List GoAwayForever(IEnumerable code) { - if (ShouldPurge(__instance)) - { - __result = (PileType.None, CardPilePosition.Bottom); - return false; - } + return new InstructionPatcher(code) + .Match(new CallMatcher(typeof(CardModel).PropertyGetter("IsDupe"))) + .Insert([ + CodeInstruction.LoadArgument(0), + CodeInstruction.Call(typeof(BetaPurgePatch), nameof(BetaPurgePatch.AlterResult)) + ]); + } - return true; + private static bool AlterResult(bool origIsDupe, CardModel card) + { + return origIsDupe || ShouldPurge(card); } } diff --git a/Patches/Hooks/MaxHandSizePatches.cs b/Patches/Hooks/MaxHandSizePatches.cs index 3991a31b..941b0731 100644 --- a/Patches/Hooks/MaxHandSizePatches.cs +++ b/Patches/Hooks/MaxHandSizePatches.cs @@ -175,7 +175,9 @@ private static IEnumerable Transpiler(IEnumerable AccessTools.AsyncMoveNext( - AccessTools.Method(typeof(CardPileCmd), nameof(CardPileCmd.Draw), + AccessTools.Method(typeof(CardPileCmd), "DrawInternal", //BETA COMPAT + [typeof(PlayerChoiceContext), typeof(decimal), typeof(Player), typeof(bool)]) + ?? AccessTools.Method(typeof(CardPileCmd), "Draw", [typeof(PlayerChoiceContext), typeof(decimal), typeof(Player), typeof(bool)])); [HarmonyTranspiler] diff --git a/Patches/Hooks/ModifyHealAmountPatches.cs b/Patches/Hooks/ModifyHealAmountPatches.cs index 58c076d9..4582f3d8 100644 --- a/Patches/Hooks/ModifyHealAmountPatches.cs +++ b/Patches/Hooks/ModifyHealAmountPatches.cs @@ -82,4 +82,4 @@ static void ModifyMultiplicative(IRunState runState, object? combatState, Creatu __result = Math.Max(0m, num); } -} +} \ No newline at end of file diff --git a/Patches/Localization/ModelLocPatch.cs b/Patches/Localization/ModelLocPatch.cs index 506023b5..92af82d6 100644 --- a/Patches/Localization/ModelLocPatch.cs +++ b/Patches/Localization/ModelLocPatch.cs @@ -28,8 +28,6 @@ class ModelLocPatch { ModelId.SlugifyCategory(nameof(RelicModel)), "relics" }, { ModelId.SlugifyCategory(nameof(DynamicVar)), "static_hover_tips" } //Does not inherit AbstractModel and so currently will not function. Here for reference purposes. }; - - //TODO - also check for ILocalizationProviders from other classes? private static readonly FieldInfo LocDictionaryField = AccessTools.Field(typeof(LocTable), "_translations"); diff --git a/Patches/PostModInitPatch.cs b/Patches/PostModInitPatch.cs index 58ae05bb..d5af27da 100644 --- a/Patches/PostModInitPatch.cs +++ b/Patches/PostModInitPatch.cs @@ -11,16 +11,17 @@ using MegaCrit.Sts2.Core.Modding; using MegaCrit.Sts2.Core.Models; using MegaCrit.Sts2.Core.Saves.Runs; -using SmartFormat; using SmartFormat.Core.Extensions; namespace BaseLib.Patches; -//Simplest patch that occurs after mod initialization, before anything else is done. -//See OneTimeInitialization.ExecuteEssential +//Patch that occurs after mod initialization, before anything else is done. +//See OneTimeInitialization.ExecuteEssential for ordering //TODO - If no mods that modify gameplay and use baselib as a dependency are enabled, exclude basemod models from database? //This would allow features like vitality to be merged. +//This seems to be something added to basegame; will be left for now. + [HarmonyPatch] class PostModInitPatch @@ -36,6 +37,8 @@ private static void EarlyPostInit() _earlyInit = true; BaseLibMain.Logger.Info("Performing early post-mod init"); + + WhatMod.BuildAfterInit(); foreach (var mod in ModManager.GetLoadedMods()) { @@ -72,13 +75,47 @@ private static void EarlyPostInit() { interop.ProcessType(harmony, type); - if (type.IsAssignableTo(typeof(IAutoRegisterFormatSpecifier)) && - type is { IsAbstract: false, IsInterface: false }) + if (type.IsAbstract || type.IsInterface) continue; + + if (type.IsAssignableTo(typeof(CustomResource))) { try { - Smart.Default.AddExtensions((IFormatter) type.CreateInstance()); - BaseLibMain.Logger.Info($"Added custom format specifier {type.Name}"); + var resourceManager = typeof(CustomResources<>).MakeGenericType(type); + var registerMethod = resourceManager.GetMethod("Register", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + + if (registerMethod == null) + { + BaseLibMain.Logger.Warn($"Failed to get registration method for custom resource type {type}"); + } + else if (Activator.CreateInstance(type) is not CustomResource resource) + { + BaseLibMain.Logger.Warn($"Failed to initialize custom resource type {type}"); + } + else + { + BaseLibMain.Logger.Info($"Registering custom resource {type.Name}"); + registerMethod.Invoke(null, [resource]); + } + } + catch (Exception e) + { + BaseLibMain.Logger.Error($"Exception occurred registering custom resource {type}; {e}"); + } + } + if (type.IsAssignableTo(typeof(IAutoRegisterFormatSpecifier))) + { + try + { + if (Activator.CreateInstance(type) is IFormatter formatter) + { + AddLaterFormatters.Add(formatter); + BaseLibMain.Logger.Info($"Instantiated custom format specifier {type.Name} to add later"); + } + else + { + BaseLibMain.Logger.Warn($"Failed to initialize IAutoRegisterFormatSpecifier type {type}"); + } } catch (Exception e) { @@ -87,6 +124,16 @@ private static void EarlyPostInit() } } } + + private static readonly List AddLaterFormatters = []; + [HarmonyPatch(typeof(LocManager), nameof(LocManager.LoadLocFormatters))] + [HarmonyPostfix] + private static void AddFormattersOnLocInit(LocManager __instance) + { + if (AddLaterFormatters.Count == 0) return; + BaseLibMain.Logger.Debug($"Added {AddLaterFormatters.Count} formatters after LoadLocFormatters."); + LocManager._smartFormatter.AddExtensions(AddLaterFormatters.ToArray()); + } /// @@ -132,15 +179,16 @@ private static void LatePostInit() CheckSpecialSpireField(field); } - //TODO - Remove on next beta->main merge; SavedPropertiesTypeCache already loads modded types. + //TODO - Remove on next beta->main merge; now already loads modded types. if (hasSavedProperty) { - if (SavedPropertiesTypeCache._cache.Count == 0) + /*if (SavedPropertiesTypeCache._cache.Count == 0) { BaseLibMain.Logger.Warn("Adding saved properties too early; type cache is still empty."); - } + }*/ - SavedPropertiesTypeCache.InjectTypeIntoCache(type); + BetaMainCompatibility.CacheSavedProperties(type); + //SavedPropertiesTypeCache.InjectTypeIntoCache(type); } } SavedSpireFieldPatch.AddFieldsSorted(); @@ -161,4 +209,28 @@ private static void CheckSpecialSpireField(FieldInfo field) field.GetValue(null); //Trigger field initialization } + + /// + /// Registers custom scene paths. + /// Called through a patch because virtual properties like CustomVisualPath + /// may depend on fields set in derived constructors that haven't run yet when + /// the base constructor occurs. + /// + [HarmonyPatch(typeof(ModelDb), nameof(ModelDb.Preload))] + class RegisterSceneConversions + { + [HarmonyPostfix] + private static void EnsureScenePathsRegistered() + { + foreach (var type in ReflectionHelper.ModTypes) + { + if (type is not { IsAbstract: false, IsInterface: false } + || !type.IsAssignableTo(typeof(AbstractModel)) + || !type.IsAssignableTo(typeof(ISceneConversions))) continue; + + var model = ModelDb.GetById(ModelDb.GetId(type)); + (model as ISceneConversions)?.RegisterSceneConversions(); + } + } + } } \ No newline at end of file diff --git a/Patches/UI/AncientSourceLabel.cs b/Patches/UI/AncientSourceLabel.cs new file mode 100644 index 00000000..69fba75a --- /dev/null +++ b/Patches/UI/AncientSourceLabel.cs @@ -0,0 +1,64 @@ +using BaseLib.Config; +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes; + +namespace BaseLib.Patches.UI; + +/// +/// Ancient events show their name as a banner instead of a hover tip. For modded ancients the source +/// mod is added as a small line under the banner's epithet, but only after the dramatic reveal has +/// settled, so it never shows during the centered intro. +/// +[HarmonyPatch(typeof(NAncientNameBanner), "AnimateVfx")] +public static class AncientSourceLabel +{ + private const string LabelName = "BaseLibModSourceLabel"; + + [HarmonyPostfix] + static void Postfix(NAncientNameBanner __instance, ref Task __result) + => __result = AddAfterSettled(__result, __instance); + + private static async Task AddAfterSettled(Task original, NAncientNameBanner banner) + { + await original; + + if (!GodotObject.IsInstanceValid(banner)) return; + if (!BaseLibConfig.ShowAncientModSource) return; + + var ancient = Traverse.Create(banner).Field("_ancient").GetValue(); + if (ancient == null) return; + + var name = WhatMod.FindModName(ancient.GetType()); + if (name == null) return; + + var epithet = banner.GetNodeOrNull("%Epithet"); + if (epithet == null || epithet.GetNodeOrNull(LabelName) != null) return; + + // Child of the now-settled epithet, so it follows its final position and 0.5 fade-in. + var label = new MegaLabel + { + Name = LabelName, + AnchorRight = 1, AnchorBottom = 1, + GrowHorizontal = Control.GrowDirection.Both, + GrowVertical = Control.GrowDirection.Both, + OffsetTop = 26, OffsetBottom = 26, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Bottom, + MinFontSize = 11, + MaxFontSize = 14, + MouseFilter = Control.MouseFilterEnum.Ignore, + }; + var font = epithet.GetThemeFont("font"); + if (font != null) label.AddThemeFontOverride("font", font); + label.AddThemeColorOverride("font_color", StsColors.cream); + label.AddThemeColorOverride("font_outline_color", Colors.Transparent); + label.SetTextAutoSize($"{name}"); + epithet.AddChild(label); + } +} diff --git a/Patches/UI/CustomResourceUiPatches.cs b/Patches/UI/CustomResourceUiPatches.cs new file mode 100644 index 00000000..cf831c62 --- /dev/null +++ b/Patches/UI/CustomResourceUiPatches.cs @@ -0,0 +1,80 @@ +using BaseLib.Abstracts; +using BaseLib.BaseLibScenes; +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.Core.Entities.Cards; +using MegaCrit.Sts2.Core.Nodes.Cards; + +namespace BaseLib.Patches.UI; + +[HarmonyPatch] +class CustomResourceUiPatches +{ + [HarmonyPatch(typeof(NCard), nameof(NCard.UpdateEnergyCostVisuals))] + [HarmonyPostfix] + static void UpdateCustomCostVisuals(NCard __instance, PileType pileType) + { + var card = __instance.Model; + if (card == null) return; + + foreach (var resourceHandler in CustomResourcePatches.RegisteredResources) + { + resourceHandler.GetCost(card)?.UpdateCostVisuals(__instance, pileType); + } + + } + + /*public static AddedNode Node = new((card) => + { + //would probably suggest loading from scene rather than this manual setup + var control = new NAdditionalCostDisplay(); + + var tex = ResourceLoader.Load("res://BaseLibTests/images/powers/power.png"); + + var size = tex.GetSize(); + var texRect = new TextureRect(); + texRect.Name = tex.ResourcePath; + texRect.Size = new(50, 50); + texRect.Texture = tex; + texRect.PivotOffset = size / 2f; + texRect.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize; + texRect.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered; + texRect.MouseFilter = Control.MouseFilterEnum.Ignore; + + control.Size = new(50, 50); + control.Position = new(-126, -231); + control.AddChild(texRect); + + var label = new Label { Text = "1" }; + label.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.Center); + control.AddChild(label); + + //For cards specifically, this is necessary to use the CardContainer instead of the NCard parent node, + //which does not receive all the transforms. + var cardContainer = card.GetChild(0)!; + cardContainer.AddChild(control); + + //Changing position to before the star icon node. + cardContainer.MoveChild(control, cardContainer.GetNode("%StarIcon").GetIndex()); + + return control; + });*/ +} + +/// +/// Interface for a class that handles the cost visuals of a custom resource. +/// +public interface ICustomCostVisualsHandler +{ + +} + +/// +/// Interface for a class that handles the resource amount display of a custom resource. +/// +public interface ICustomResourceVisualsHandler +{ + +} + diff --git a/Patches/UI/EventSourceLabel.cs b/Patches/UI/EventSourceLabel.cs new file mode 100644 index 00000000..803e13ce --- /dev/null +++ b/Patches/UI/EventSourceLabel.cs @@ -0,0 +1,74 @@ +using BaseLib.Config; +using BaseLib.Utils; +using Godot; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Models; +using MegaCrit.Sts2.Core.Nodes.Events; + +namespace BaseLib.Patches.UI; + +/// +/// Events have no hover tip, so for modded events the source mod is shown as a small corner label +/// on the event screen instead. +/// +[HarmonyPatch(typeof(NEventLayout), nameof(NEventLayout.SetEvent))] +public static class EventSourceLabel +{ + private const string LabelName = "BaseLibModSourceLabel"; + + [HarmonyPostfix] + static void AddSourceLabel(NEventLayout __instance, EventModel eventModel) + { + // Ancient events show their name as a banner; handled by AncientSourceLabel instead. + if (eventModel is AncientEventModel) return; + + var existing = __instance.GetNodeOrNull(LabelName); + + var name = BaseLibConfig.ShowEventModSource ? WhatMod.FindModName(eventModel.GetType()) : null; + if (name == null) + { + existing?.QueueFree(); + return; + } + + if (existing != null) + { + existing.SetTextAutoSize(name); + return; + } + + var label = new MegaLabel + { + Name = LabelName, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + MinFontSize = 24, + MaxFontSize = 24, + MouseFilter = Control.MouseFilterEnum.Ignore, + Modulate = Colors.White with { A = 0.7f }, + }; + var font = ResourceLoader.Load("res://themes/kreon_regular_shared.tres"); + if (font != null) label.AddThemeFontOverride("font", font); + label.AddThemeColorOverride("font_color", StsColors.cream); + label.AddThemeColorOverride("font_outline_color", Colors.Black with { A = 0.55f }); + label.AddThemeConstantOverride("outline_size", 6); + label.SetTextAutoSize(name); + __instance.AddChild(label); + + // Pin to the bottom-left of the screen with equal margins, using global coordinates so the + // nested event-room containers' geometry doesn't matter. Reposition when sizes settle. + const float margin = 56f; + void Place() + { + if (!GodotObject.IsInstanceValid(label)) return; + var viewport = label.GetViewportRect().Size; + label.GlobalPosition = new Vector2(margin, viewport.Y - margin - label.Size.Y); + } + Place(); + label.Resized += Place; + __instance.Resized += Place; + } +} diff --git a/Patches/UI/HealthBarForecastPatch.cs b/Patches/UI/HealthBarForecastPatch.cs index 530b1da1..8c97f695 100644 --- a/Patches/UI/HealthBarForecastPatch.cs +++ b/Patches/UI/HealthBarForecastPatch.cs @@ -13,8 +13,8 @@ namespace BaseLib.Patches.UI; /// and lethal HP label tinting from data. /// /// -/// When no segments apply, vanilla visuals are unchanged. Right-side segments layer above poison; left-side above -/// doom. +/// When no segments apply, vanilla visuals are unchanged. Right-side segments layer above poison; left-side segments +/// share the doom side according to their configured left-origin layout. /// [HarmonyPatch] public static class HealthBarForecastPatch @@ -24,11 +24,26 @@ public static class HealthBarForecastPatch private static readonly Color DoomLethalTextColor = new("FB8DFF"); private static readonly Color DoomLethalOutlineColor = new("2D1263"); + [ThreadStatic] private static bool _isRefreshingOverlay; + [HarmonyPatch(typeof(NHealthBar), "RefreshForeground")] [HarmonyPostfix] private static void RefreshForegroundPostfix(NHealthBar __instance) { - RefreshForegroundOverlay(__instance); + RunOverlayRefresh(__instance); + } + + [HarmonyPatch(typeof(NHealthBar), "SetHpBarContainerSizeWithOffsetsImmediately")] + [HarmonyPostfix] + private static void SetHpBarContainerSizeWithOffsetsImmediatelyPostfix(NHealthBar __instance) + { + if (__instance._creature == null) + return; + + if (!RunOverlayRefresh(__instance)) + return; + + SnapMiddlegroundToForecast(__instance); } [HarmonyPatch(typeof(NHealthBar), "RefreshMiddleground")] @@ -45,6 +60,23 @@ private static void RefreshTextPostfix(NHealthBar __instance) RefreshTextOverlay(__instance); } + private static bool RunOverlayRefresh(NHealthBar healthBar) + { + if (_isRefreshingOverlay) + return false; + + _isRefreshingOverlay = true; + try + { + RefreshForegroundOverlay(healthBar); + return true; + } + finally + { + _isRefreshingOverlay = false; + } + } + private static void RefreshForegroundOverlay(NHealthBar healthBar) { var creature = healthBar._creature; @@ -67,13 +99,14 @@ private static void RefreshForegroundOverlay(NHealthBar healthBar) var state = UiStates[healthBar]; if (state == null) return; + EnsureOverlayOrder(healthBar, state); var maxWidth = GetMaxFgWidth(healthBar); + var visualDenom = creature.MaxHp; var hpForeground = healthBar._hpForeground; - var hpFromForeground = - Math.Clamp(HpFromOffsetRight(healthBar, hpForeground.OffsetRight), 0, creature.CurrentHp); - var baseHp = hpForeground.Visible || hpFromForeground < creature.CurrentHp ? hpFromForeground : 0; + var poisonDamage = Math.Max(0, creature.GetPower()?.CalculateTotalDamageNextTurn() ?? 0); + var baseHp = Math.Max(0, creature.CurrentHp - poisonDamage); var rightSegments = customSegments .Where(segment => segment.Direction == HealthBarForecastDirection.FromRight) @@ -100,8 +133,8 @@ private static void RefreshForegroundOverlay(NHealthBar healthBar) var previousHp = remainingHp; remainingHp -= visibleAmount; - var leftWidth = GetFgWidth(healthBar, remainingHp); - var rightWidth = GetFgWidth(healthBar, previousHp); + var leftWidth = GetFgWidth(healthBar, remainingHp, visualDenom); + var rightWidth = GetFgWidth(healthBar, previousHp, visualDenom); node.Visible = true; ApplyForecastSegmentAppearance(node, segment.Color, segment.OverlayMaterial, segment.OverlaySelfModulate); node.OffsetLeft = remainingHp > 0 ? Math.Max(0f, leftWidth - node.PatchMarginLeft) : 0f; @@ -111,7 +144,7 @@ private static void RefreshForegroundOverlay(NHealthBar healthBar) rightForecastEdgeOffsetRight = node.OffsetRight; if (remainingHp <= 0) - lethalRightColor = segment.Color; + lethalRightColor = segment.AffectsHpLabel ? segment.Color : null; rightIndex++; } @@ -123,7 +156,7 @@ private static void RefreshForegroundOverlay(NHealthBar healthBar) if (remainingHp > 0) { hpForeground.Visible = true; - hpForeground.OffsetRight = GetFgWidth(healthBar, remainingHp) - maxWidth; + hpForeground.OffsetRight = GetFgWidth(healthBar, remainingHp, visualDenom) - maxWidth; } else { @@ -143,8 +176,8 @@ private static void RefreshForegroundOverlay(NHealthBar healthBar) if (remainingHp <= 0) { HideSegments(state.LeftSegments); - state.LastRender = - new HealthBarForecastRenderResult(true, rightForecastEdgeOffsetRight, lethalRightColor, null); + state.OverlapLeftZ.Clear(); + state.LastRender = new(true, rightForecastEdgeOffsetRight, lethalRightColor, null, 0); return; } @@ -154,46 +187,53 @@ private static void RefreshForegroundOverlay(NHealthBar healthBar) .ThenBy(segment => segment.SequenceOrder) .ToArray(); - var leftAccumulated = 0; + state.OverlapLeftZ.Clear(); var leftIndex = 0; - - foreach (var segment in leftSegments) - { - if (leftAccumulated >= remainingHp) - break; - - var segmentStart = leftAccumulated; - leftAccumulated = Math.Min(remainingHp, leftAccumulated + segment.Amount); - if (leftAccumulated <= segmentStart) - continue; - - EnsureSegmentCount(state.LeftSegments, state.LeftContainer, leftIndex + 1, state.LeftTemplate); - var node = state.LeftSegments[leftIndex]; - var startWidth = GetFgWidth(healthBar, segmentStart); - var endWidth = GetFgWidth(healthBar, leftAccumulated); - - node.Visible = true; - ApplyForecastSegmentAppearance(node, segment.Color, segment.OverlayMaterial, segment.OverlaySelfModulate); - node.OffsetLeft = segmentStart > 0 ? Math.Max(0f, startWidth - node.PatchMarginLeft) : 0f; - var leftOffsetRight = Math.Min(0f, endWidth - maxWidth + node.PatchMarginRight); - if (rightIndex > 0) - leftOffsetRight = Math.Min(leftOffsetRight, rightForecastEdgeOffsetRight); - node.OffsetRight = leftOffsetRight; - - leftIndex++; - } + var chainedLeft = leftSegments + .Where(segment => segment.LeftOriginLayout == HealthBarForecastLeftOriginLayout.Chained) + .ToArray(); + PlaceChainedLeftSegments( + healthBar, + state, + chainedLeft, + remainingHp, + maxWidth, + rightIndex, + rightForecastEdgeOffsetRight, + visualDenom, + ref leftIndex); + + var overlapLeft = leftSegments + .Where(segment => segment.LeftOriginLayout == HealthBarForecastLeftOriginLayout.OverlapFromOrigin) + .ToArray(); + PlaceOverlapLeftSegments( + healthBar, + state, + overlapLeft, + remainingHp, + maxWidth, + rightIndex, + rightForecastEdgeOffsetRight, + visualDenom, + ref leftIndex); HideSegments(state.LeftSegments, leftIndex); - var lethalLeftColor = ResolveLeftLethalColor(creature, remainingHp, leftSegments); - state.LastRender = new HealthBarForecastRenderResult(rightIndex > 0, rightForecastEdgeOffsetRight, - lethalRightColor, lethalLeftColor); + var lethalLeftColor = ResolveLeftLethalColor(creature, remainingHp, leftSegments, state.OverlapLeftZ); + state.LastRender = + new(rightIndex > 0, rightForecastEdgeOffsetRight, lethalRightColor, lethalLeftColor, remainingHp); } private static void RefreshMiddlegroundOverlay(NHealthBar healthBar) { var state = UiStates[healthBar]; - if (state == null || !state.LastRender.HasRightForecast) + if (state == null) + return; + + if (!state.LastRender.HasRightForecast) + { + state.MiddlegroundTweenTarget = null; return; + } var creature = healthBar._creature; if (creature.CurrentHp <= 0 || creature.HpDisplay.IsInfinite()) @@ -201,6 +241,17 @@ private static void RefreshMiddlegroundOverlay(NHealthBar healthBar) var hpMiddleground = healthBar._hpMiddleground; var targetOffsetRight = state.LastRender.RightForecastEdgeOffsetRight; + var hpChanged = creature.CurrentHp != state.MiddlegroundHpOnLastTween || + creature.MaxHp != state.MiddlegroundMaxHpOnLastTween; + var targetChanged = state.MiddlegroundTweenTarget is not { } lastTarget || + !Mathf.IsEqualApprox(lastTarget, targetOffsetRight); + if (!hpChanged && !targetChanged) + return; + + state.MiddlegroundHpOnLastTween = creature.CurrentHp; + state.MiddlegroundMaxHpOnLastTween = creature.MaxHp; + state.MiddlegroundTweenTarget = targetOffsetRight; + var shouldAnimateImmediately = targetOffsetRight >= hpMiddleground.OffsetRight; hpMiddleground.OffsetRight += 1f; @@ -213,6 +264,24 @@ private static void RefreshMiddlegroundOverlay(NHealthBar healthBar) healthBar._middlegroundTween = tween; } + private static void SnapMiddlegroundToForecast(NHealthBar healthBar) + { + var state = UiStates[healthBar]; + if (state == null || !state.LastRender.HasRightForecast) + return; + + var creature = healthBar._creature; + if (creature.CurrentHp <= 0 || BetaMainCompatibility.Creature_.ShowsInfiniteHp(creature)) + return; + + var targetOffsetRight = state.LastRender.RightForecastEdgeOffsetRight; + healthBar._middlegroundTween?.Kill(); + healthBar._hpMiddleground.OffsetRight = targetOffsetRight - 2f; + state.MiddlegroundHpOnLastTween = creature.CurrentHp; + state.MiddlegroundMaxHpOnLastTween = creature.MaxHp; + state.MiddlegroundTweenTarget = targetOffsetRight; + } + private static void RefreshTextOverlay(NHealthBar healthBar) { var state = UiStates[healthBar]; @@ -238,6 +307,91 @@ private static void RefreshTextOverlay(NHealthBar healthBar) hpLabel.AddThemeColorOverride("font_outline_color", DarkenForOutline(lethalColor.Value)); } + private static void PlaceChainedLeftSegments( + NHealthBar healthBar, + HealthBarForecastUiState state, + CustomSegment[] chainedOrdered, + int remainingHp, + float maxWidth, + int rightIndex, + float rightForecastEdgeOffsetRight, + int visualDenom, + ref int leftIndex) + { + var leftAccumulated = 0; + foreach (var segment in chainedOrdered) + { + if (leftAccumulated >= remainingHp) + break; + + var segmentStart = leftAccumulated; + leftAccumulated = Math.Min(remainingHp, leftAccumulated + segment.Amount); + if (leftAccumulated <= segmentStart) + continue; + + EnsureSegmentCount(state.LeftSegments, state.LeftContainer, leftIndex + 1, state.LeftTemplate); + var node = state.LeftSegments[leftIndex]; + var startWidth = GetFgWidth(healthBar, segmentStart, visualDenom); + var endWidth = GetFgWidth(healthBar, leftAccumulated, visualDenom); + + node.Visible = true; + ApplyForecastSegmentAppearance(node, segment.Color, segment.OverlayMaterial, segment.OverlaySelfModulate); + node.OffsetLeft = segmentStart > 0 ? Math.Max(0f, startWidth - node.PatchMarginLeft) : 0f; + var leftOffsetRight = Math.Min(0f, endWidth - maxWidth + node.PatchMarginRight); + if (rightIndex > 0) + leftOffsetRight = Math.Min(leftOffsetRight, rightForecastEdgeOffsetRight); + node.OffsetRight = leftOffsetRight; + + leftIndex++; + } + } + + private static void PlaceOverlapLeftSegments( + NHealthBar healthBar, + HealthBarForecastUiState state, + CustomSegment[] overlapSegments, + int remainingHp, + float maxWidth, + int rightIndex, + float rightForecastEdgeOffsetRight, + int visualDenom, + ref int leftIndex) + { + if (overlapSegments.Length == 0) + return; + + foreach (var group in overlapSegments.GroupBy(segment => segment.LeftExclusiveZGroup).OrderBy(group => group.Key)) + { + var sorted = group + .OrderByDescending(segment => segment.Amount) + .ThenBy(segment => segment.Order) + .ThenBy(segment => segment.SequenceOrder) + .ToArray(); + + foreach (var segment in sorted) + { + var visibleAmount = Math.Min(segment.Amount, remainingHp); + if (visibleAmount <= 0) + continue; + + EnsureSegmentCount(state.LeftSegments, state.LeftContainer, leftIndex + 1, state.LeftTemplate); + var node = state.LeftSegments[leftIndex]; + var endWidth = GetFgWidth(healthBar, visibleAmount, visualDenom); + state.OverlapLeftZ.Add((segment, leftIndex)); + + node.Visible = true; + ApplyForecastSegmentAppearance(node, segment.Color, segment.OverlayMaterial, segment.OverlaySelfModulate); + node.OffsetLeft = 0f; + var leftOffsetRight = Math.Min(0f, endWidth - maxWidth + node.PatchMarginRight); + if (rightIndex > 0) + leftOffsetRight = Math.Min(leftOffsetRight, rightForecastEdgeOffsetRight); + node.OffsetRight = leftOffsetRight; + + leftIndex++; + } + } + } + private static CustomSegment[] GetCustomSegments(Creature creature) { return HealthBarForecastRegistry.GetSegments(creature) @@ -248,7 +402,10 @@ private static CustomSegment[] GetCustomSegments(Creature creature) registered.Segment.Order, registered.SequenceOrder, registered.Segment.OverlayMaterial, - registered.Segment.OverlaySelfModulate)) + registered.Segment.OverlaySelfModulate, + registered.Segment.LeftOriginLayout, + registered.Segment.LeftExclusiveZGroup, + registered.Segment.AffectsHpLabel)) .Where(segment => segment.Amount > 0) .ToArray(); } @@ -261,6 +418,7 @@ private static void HideAllCustomSegments(NHealthBar healthBar) HideSegments(state.RightSegments); HideSegments(state.LeftSegments); + state.OverlapLeftZ.Clear(); state.LastRender = HealthBarForecastRenderResult.Empty; } @@ -284,11 +442,16 @@ private static bool EnsureUiState(NHealthBar healthBar) mask.AddChild(rightContainer); mask.AddChild(leftContainer); + var rightTemplate = CreateSegmentTemplate(poisonForeground, "BaseLibForecastRightTemplate"); + var leftTemplate = CreateSegmentTemplate(doomForeground, "BaseLibForecastLeftTemplate"); + rightContainer.AddChild(rightTemplate); + leftContainer.AddChild(leftTemplate); + UiStates[healthBar] = new HealthBarForecastUiState( rightContainer, leftContainer, - CreateSegmentTemplate(poisonForeground, "BaseLibForecastRightTemplate"), - CreateSegmentTemplate(doomForeground, "BaseLibForecastLeftTemplate"), + rightTemplate, + leftTemplate, []); return true; } @@ -310,30 +473,54 @@ private static NinePatchRect CreateSegmentTemplate(NinePatchRect template, strin var duplicate = (NinePatchRect)template.Duplicate(); duplicate.Name = name; duplicate.Visible = false; + duplicate.Modulate = Colors.White; duplicate.SelfModulate = Colors.White; duplicate.Material = null; + duplicate.ZIndex = 0; + duplicate.MouseFilter = Control.MouseFilterEnum.Ignore; + duplicate.OffsetLeft = 0f; + duplicate.OffsetRight = 0f; return duplicate; } private static void EnsureOverlayOrder(NHealthBar healthBar, HealthBarForecastUiState state) { - if (healthBar._poisonForeground is not Control poisonForeground || - healthBar._hpForeground is not Control hpForeground || - healthBar._doomForeground is not Control doomForeground || + if (healthBar._poisonForeground is not { } poisonForeground || + healthBar._hpForeground is not { } hpForeground || + healthBar._doomForeground is not { } doomForeground || poisonForeground.GetParent() is not Control mask) return; - // Right forecast should override poison, but still be clipped by HP. - var poisonIndex = poisonForeground.GetIndex(); - var hpIndex = hpForeground.GetIndex(); - var rightTargetIndex = Math.Clamp(poisonIndex + 1, 0, hpIndex); - mask.MoveChild(state.RightContainer, rightTargetIndex); - - // Left forecast should override doom-like overlays. - var doomIndex = doomForeground.GetIndex(); - var childCount = mask.GetChildCount(); - var leftTargetIndex = Math.Clamp(doomIndex + 1, 0, Math.Max(0, childCount - 1)); - mask.MoveChild(state.LeftContainer, leftTargetIndex); + if (poisonForeground.GetIndex() < hpForeground.GetIndex()) + MoveChildAfter(mask, state.RightContainer, poisonForeground); + else + MoveChildBefore(mask, state.RightContainer, hpForeground); + + MoveChildBefore(mask, state.LeftContainer, doomForeground); + } + + private static void MoveChildAfter(Control parent, Control node, Control anchor) + { + if (node.GetParent() != parent || anchor.GetParent() != parent) + return; + + var nodeIndex = node.GetIndex(); + var anchorIndex = anchor.GetIndex(); + var targetIndex = nodeIndex > anchorIndex ? anchorIndex + 1 : anchorIndex; + if (nodeIndex != targetIndex) + parent.MoveChild(node, targetIndex); + } + + private static void MoveChildBefore(Control parent, Control node, Control anchor) + { + if (node.GetParent() != parent || anchor.GetParent() != parent) + return; + + var nodeIndex = node.GetIndex(); + var anchorIndex = anchor.GetIndex(); + var targetIndex = nodeIndex > anchorIndex ? anchorIndex : Math.Max(0, anchorIndex - 1); + if (nodeIndex != targetIndex) + parent.MoveChild(node, targetIndex); } private static void EnsureSegmentCount( @@ -363,6 +550,7 @@ private static void HideSegments(IEnumerable segments, int startI segment.Visible = false; segment.Material = null; segment.SelfModulate = Colors.White; + segment.ZIndex = 0; } } @@ -388,30 +576,16 @@ private static float GetMaxFgWidth(NHealthBar healthBar) : healthBar._hpForegroundContainer.Size.X; } - private static float GetFgWidth(NHealthBar healthBar, int amount) + private static float GetFgWidth(NHealthBar healthBar, int amount, int visualDenom) { var creature = healthBar._creature; - if (creature.MaxHp <= 0 || amount <= 0) + if (visualDenom <= 0 || amount <= 0) return 0f; - var width = (float)amount / creature.MaxHp * GetMaxFgWidth(healthBar); + var width = (float)amount / visualDenom * GetMaxFgWidth(healthBar); return Math.Max(width, creature.CurrentHp > 0 ? 12f : 0f); } - private static int HpFromOffsetRight(NHealthBar healthBar, float offsetRight) - { - var creature = healthBar._creature; - if (creature.MaxHp <= 0) - return 0; - - var maxWidth = GetMaxFgWidth(healthBar); - if (maxWidth <= 0f) - return 0; - - var width = Math.Clamp(offsetRight + maxWidth, 0f, maxWidth); - return (int)Math.Round(width / maxWidth * creature.MaxHp); - } - private static Color DarkenForOutline(Color color) { return new Color( @@ -426,27 +600,52 @@ private static bool IsDoomLethalAfterRight(NHealthBar healthBar, Creature creatu if (doomAmount <= 0) return false; - var hpAfterRight = Math.Clamp( - HpFromOffsetRight(healthBar, healthBar._hpForeground.OffsetRight), - 0, - creature.CurrentHp); - return hpAfterRight > 0 && doomAmount >= hpAfterRight; + var state = UiStates[healthBar]; + if (state == null || !state.LastRender.HasRightForecast) + return false; + + var remainingHp = state.LastRender.RemainingHpAfterRight; + return remainingHp > 0 && doomAmount >= remainingHp; } - private static Color? ResolveLeftLethalColor(Creature creature, int remainingHp, - IReadOnlyList leftSegments) + private static Color? ResolveLeftLethalColor( + Creature creature, + int remainingHp, + IReadOnlyList leftSegments, + List<(CustomSegment Segment, int DrawIndex)> overlapZ) { if (remainingHp <= 0) return null; - List candidates = []; - foreach (var segment in leftSegments) + Color? overlapLethal = null; + var hasOverlapLethal = false; + var bestDrawIndex = int.MinValue; + foreach (var (segment, drawIndex) in overlapZ) { - if (segment.Amount <= 0) + if (segment.Amount < remainingHp) continue; - candidates.Add(new LethalCandidate(segment.Amount, segment.Color, segment.Order, segment.SequenceOrder)); + if (drawIndex < bestDrawIndex) + continue; + + bestDrawIndex = drawIndex; + hasOverlapLethal = true; + overlapLethal = segment.AffectsHpLabel ? segment.Color : null; } + if (hasOverlapLethal) + return overlapLethal; + + List candidates = []; + candidates.AddRange(from segment in leftSegments + where segment is + { + Amount: > 0, + Direction: HealthBarForecastDirection.FromLeft, + LeftOriginLayout: HealthBarForecastLeftOriginLayout.Chained + } + select new LethalCandidate(segment.Amount, segment.AffectsHpLabel ? segment.Color : null, segment.Order, + segment.SequenceOrder)); + var doomAmount = creature.GetPowerAmount(); if (doomAmount > 0) candidates.Add(new LethalCandidate(doomAmount, DoomLethalTextColor, 0, long.MinValue / 4)); @@ -482,7 +681,11 @@ private sealed class HealthBarForecastUiState( public NinePatchRect LeftTemplate { get; } = leftTemplate; public List RightSegments { get; } = rightSegments; public List LeftSegments { get; } = []; + public List<(CustomSegment Segment, int DrawIndex)> OverlapLeftZ { get; } = []; public HealthBarForecastRenderResult LastRender { get; set; } = HealthBarForecastRenderResult.Empty; + public float? MiddlegroundTweenTarget { get; set; } + public int MiddlegroundHpOnLastTween { get; set; } = -1; + public int MiddlegroundMaxHpOnLastTween { get; set; } = -1; } private readonly record struct CustomSegment( @@ -492,11 +695,14 @@ private readonly record struct CustomSegment( int Order, long SequenceOrder, Material? OverlayMaterial, - Color? OverlaySelfModulate); + Color? OverlaySelfModulate, + HealthBarForecastLeftOriginLayout LeftOriginLayout, + int LeftExclusiveZGroup, + bool AffectsHpLabel); private readonly record struct LethalCandidate( int Amount, - Color Color, + Color? Color, int Order, long SequenceOrder); @@ -504,8 +710,9 @@ private readonly record struct HealthBarForecastRenderResult( bool HasRightForecast, float RightForecastEdgeOffsetRight, Color? LethalRightColor, - Color? LethalLeftColor) + Color? LethalLeftColor, + int RemainingHpAfterRight) { - public static HealthBarForecastRenderResult Empty => new(false, 0f, null, null); + public static HealthBarForecastRenderResult Empty => new(false, 0f, null, null, 0); } -} \ No newline at end of file +} diff --git a/Patches/UI/MerchantCharacterAnimPatch.cs b/Patches/UI/MerchantCharacterAnimPatch.cs index 0bf1fe05..5a615e94 100644 --- a/Patches/UI/MerchantCharacterAnimPatch.cs +++ b/Patches/UI/MerchantCharacterAnimPatch.cs @@ -1,17 +1,41 @@ using BaseLib.Utils; +using BaseLib.Utils.NodeFactories; +using Godot; using HarmonyLib; using MegaCrit.Sts2.Core.Bindings.MegaSpine; using MegaCrit.Sts2.Core.Nodes.Screens.Shops; namespace BaseLib.Patches.UI; -[HarmonyPatch(typeof(NMerchantCharacter), nameof(NMerchantCharacter.PlayAnimation))] +[HarmonyPatch] class MerchantCharacterAnimPatch { + [HarmonyPatch(typeof(NMerchantCharacter), nameof(NMerchantCharacter._Ready))] [HarmonyPrefix] - public static bool SkipAnimIfNotSpine(NMerchantCharacter __instance, string anim, bool loop) + public static bool SkipInitialAnimIfNotSpine(NMerchantCharacter __instance) { - return !CustomAnimation.PlayCustomAnimation(__instance, GetAnimNames(anim)); + if (!NodeFactory.CreatedFromFactory(__instance)) return true; + + if (CustomAnimation.HasCustomAnimation(__instance)) return false; + if (__instance.GetChildCount() == 0) return false; + if (__instance.GetChild(0) is not GodotObject godotObj + || godotObj.GetClass() != MegaSprite.spineClassName) return false; + + return true; + } + + [HarmonyPatch(typeof(NMerchantCharacter), nameof(NMerchantCharacter.PlayAnimation))] + [HarmonyPrefix] + public static bool PlayAlternateAnimation(NMerchantCharacter __instance, string anim, bool loop) + { + if (!NodeFactory.CreatedFromFactory(__instance)) return true; + + if (CustomAnimation.PlayCustomAnimation(__instance, GetAnimNames(anim))) return false; + if (__instance.GetChildCount() == 0) return false; + if (__instance.GetChild(0) is not GodotObject godotObj + || godotObj.GetClass() != MegaSprite.spineClassName) return false; + + return true; } private static string[] GetAnimNames(string animName) diff --git a/Patches/UI/ModSourceTooltip.cs b/Patches/UI/ModSourceTooltip.cs new file mode 100644 index 00000000..227723c9 --- /dev/null +++ b/Patches/UI/ModSourceTooltip.cs @@ -0,0 +1,118 @@ +using System.Reflection; +using BaseLib.Config; +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.Core.HoverTips; +using MegaCrit.Sts2.Core.Localization; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Patches.UI; + +/// +/// Shows a "from which mod?" line for modded content. Content that already has its own text tip +/// (relics, powers, potions, enchantments, afflictions, orbs) gets the line folded into that tip; +/// cards, which have no own text tip on hover, get a dedicated tip instead. (Monsters are handled +/// on their nameplate by .) +/// +[HarmonyPatch] +static class ModSourceTooltip +{ + private const string FoldedLineColor = "#8a8a8a"; + + private static readonly FieldInfo? DescriptionField = + AccessTools.Field(typeof(HoverTip), "k__BackingField"); + + private static LocString TitleLoc() => new("gameplay_ui", "BASELIB-MOD_SOURCE.title"); + + /// + /// Folds the source line into the model's own hover tip (the first tip, or the last for orbs). + /// + private static IEnumerable Fold(IEnumerable tips, AbstractModel model, bool foldLast = false) + { + var name = WhatMod.FindModName(model.GetType()); + if (name == null) return tips; + + var list = tips.ToList(); + var index = foldLast ? list.Count - 1 : 0; + if (DescriptionField != null && index >= 0 && list[index] is HoverTip own) + { + // Box a copy and edit that, so a cached/shared original tip is never mutated. + object boxed = own; + var line = $"[color={FoldedLineColor}]{TitleLoc().GetFormattedText()}: {name}[/color]"; + DescriptionField.SetValue(boxed, $"{own.Description}\n{line}"); + list[index] = (IHoverTip)boxed; + } + else + { + list.Add(new HoverTip(TitleLoc(), name) { Id = $"BASELIB-MOD_SOURCE-{name}" }); + } + return list; + } + + /// + /// Appends a dedicated source tip for content with no own text tip on hover. + /// + private static IEnumerable AppendBox(IEnumerable tips, AbstractModel model) + { + var name = WhatMod.FindModName(model.GetType()); + return name == null + ? tips + : tips.Append(new HoverTip(TitleLoc(), name) { Id = $"BASELIB-MOD_SOURCE-{name}" }); + } + + [HarmonyPatch(typeof(RelicModel), nameof(RelicModel.HoverTips), MethodType.Getter)] + private static class RelicTips + { + [HarmonyPostfix] + static void Postfix(RelicModel __instance, ref IEnumerable __result) + => __result = BaseLibConfig.ShowRelicModSource ? Fold(__result, __instance) : __result; + } + + [HarmonyPatch(typeof(PotionModel), nameof(PotionModel.HoverTips), MethodType.Getter)] + private static class PotionTips + { + [HarmonyPostfix] + static void Postfix(PotionModel __instance, ref IEnumerable __result) + => __result = BaseLibConfig.ShowPotionModSource ? Fold(__result, __instance) : __result; + } + + [HarmonyPatch(typeof(CardModel), nameof(CardModel.HoverTips), MethodType.Getter)] + private static class CardTips + { + [HarmonyPostfix] + static void Postfix(CardModel __instance, ref IEnumerable __result) + => __result = BaseLibConfig.ShowCardModSource ? AppendBox(__result, __instance) : __result; + } + + [HarmonyPatch(typeof(PowerModel), nameof(PowerModel.HoverTips), MethodType.Getter)] + private static class PowerTips + { + [HarmonyPostfix] + static void Postfix(PowerModel __instance, ref IEnumerable __result) + => __result = BaseLibConfig.ShowCombatElementModSource ? Fold(__result, __instance) : __result; + } + + [HarmonyPatch(typeof(OrbModel), nameof(OrbModel.HoverTips), MethodType.Getter)] + private static class OrbTips + { + [HarmonyPostfix] + static void Postfix(OrbModel __instance, ref IEnumerable __result) + => __result = BaseLibConfig.ShowCombatElementModSource ? Fold(__result, __instance, foldLast: true) : __result; + } + + [HarmonyPatch(typeof(EnchantmentModel), nameof(EnchantmentModel.HoverTips), MethodType.Getter)] + private static class EnchantmentTips + { + [HarmonyPostfix] + static void Postfix(EnchantmentModel __instance, ref IEnumerable __result) + => __result = BaseLibConfig.ShowCombatElementModSource ? Fold(__result, __instance) : __result; + } + + [HarmonyPatch(typeof(AfflictionModel), nameof(AfflictionModel.HoverTips), MethodType.Getter)] + private static class AfflictionTips + { + [HarmonyPostfix] + static void Postfix(AfflictionModel __instance, ref IEnumerable __result) + => __result = BaseLibConfig.ShowCombatElementModSource ? Fold(__result, __instance) : __result; + } +} diff --git a/Patches/UI/MonsterSourceLabel.cs b/Patches/UI/MonsterSourceLabel.cs new file mode 100644 index 00000000..e98197f7 --- /dev/null +++ b/Patches/UI/MonsterSourceLabel.cs @@ -0,0 +1,31 @@ +using BaseLib.Config; +using BaseLib.Utils; +using HarmonyLib; +using MegaCrit.Sts2.addons.mega_text; +using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Nodes.Combat; + +namespace BaseLib.Patches.UI; + +/// +/// Monsters have no own text hover tip, so for modded monsters the source mod is shown directly +/// under the monster's name on its nameplate. +/// +[HarmonyPatch(typeof(NCreatureStateDisplay), "RefreshValues")] +static class MonsterSourceLabel +{ + [HarmonyPostfix] + static void Postfix(NCreatureStateDisplay __instance) + { + if (!BaseLibConfig.ShowMonsterModSource) return; + + var creature = __instance._creature; + var monster = __instance._creature?.Monster; + if (creature == null || monster == null) return; + + var name = WhatMod.FindModName(monster.GetType()); + if (name == null) return; + + __instance._nameplateLabel?.SetTextAutoSize($"{creature.Name}\n{name}"); + } +} diff --git a/Patches/UI/SceneConversionPatch.cs b/Patches/UI/SceneConversionPatch.cs index 6ced7be7..56f612c6 100644 --- a/Patches/UI/SceneConversionPatch.cs +++ b/Patches/UI/SceneConversionPatch.cs @@ -1,10 +1,7 @@ using System.Reflection; -using BaseLib.Abstracts; -using BaseLib.Patches.Content; using BaseLib.Utils.NodeFactories; using Godot; using HarmonyLib; -using MegaCrit.Sts2.Core.Models; namespace BaseLib.Patches.UI; @@ -40,25 +37,3 @@ static void Postfix(PackedScene __instance, ref Node? __result) NodeFactory.TryAutoConvert(__instance, ref __result); } } - -/// -/// Registers custom scene paths. -/// Called through a patch because virtual properties like CustomVisualPath -/// may depend on fields set in derived constructors that haven't run yet when -/// the base constructor occurs. -/// -[HarmonyPatch(typeof(ModelDb), nameof(ModelDb.Preload))] -class RegisterSceneConversions -{ - [HarmonyPostfix] - private static void EnsureScenePathsRegistered() - { - foreach (var type in CustomContentDictionary.RegisteredTypes) - { - if (!type.IsAssignableTo(typeof(ISceneConversions))) continue; - - var model = ModelDb.GetById(ModelDb.GetId(type)); - (model as ISceneConversions)?.RegisterSceneConversions(); - } - } -} diff --git a/Patches/Utils/SavedSpireFieldPatch.cs b/Patches/Utils/SavedSpireFieldPatch.cs index 1e516ef8..6ba2d319 100644 --- a/Patches/Utils/SavedSpireFieldPatch.cs +++ b/Patches/Utils/SavedSpireFieldPatch.cs @@ -2,6 +2,7 @@ using BaseLib.Utils; using Godot; using HarmonyLib; +using MegaCrit.Sts2.Core.Multiplayer.Serialization; using MegaCrit.Sts2.Core.Saves.Runs; namespace BaseLib.Patches.Utils; @@ -57,17 +58,30 @@ internal static void AddFieldsSorted() } } } - + + /// + /// Used to add names for fake saved properties. + /// + private static Type? _targetType; + private static bool _beta = false; private static void InjectNameIntoBaseGameCache(string name) { + if (_targetType == null) + { + _targetType = + AccessTools.TypeByName("MegaCrit.Sts2.Core.Saves.Runs.SavedPropertiesTypeCache"); + if (_targetType == null) + { + _targetType = typeof(ModelIdSerializationCache); + _beta = true; + } + } var propertyToId = AccessTools.StaticFieldRefAccess>( - typeof(SavedPropertiesTypeCache), - "_propertyNameToNetIdMap" - ); + _targetType, "_propertyNameToNetIdMap"); var idToProperty = AccessTools.StaticFieldRefAccess>( - typeof(SavedPropertiesTypeCache), - "_netIdToPropertyNameMap" - ); + _targetType, "_netIdToPropertyNameMap"); + var bitSizeProperty = AccessTools.PropertySetter( + _targetType, _beta ? "PropertyIdBitSize" : "NetIdBitSize"); if (!propertyToId.ContainsKey(name)) { @@ -78,9 +92,7 @@ private static void InjectNameIntoBaseGameCache(string name) int newBitSize = Mathf.CeilToInt(Math.Log2(idToProperty.Count)); - AccessTools - .Property(typeof(SavedPropertiesTypeCache), "NetIdBitSize") - .SetValue(null, newBitSize); + bitSizeProperty.Invoke(null, [newBitSize]); } else { diff --git a/Utils/BetaMainCompatibility.cs b/Utils/BetaMainCompatibility.cs index 461d3b8f..73aa6e47 100644 --- a/Utils/BetaMainCompatibility.cs +++ b/Utils/BetaMainCompatibility.cs @@ -8,6 +8,7 @@ using MegaCrit.Sts2.Core.Commands.Builders; using MegaCrit.Sts2.Core.Entities.Cards; using MegaCrit.Sts2.Core.Entities.Creatures; +using MegaCrit.Sts2.Core.Entities.Multiplayer; using MegaCrit.Sts2.Core.Entities.Players; using MegaCrit.Sts2.Core.GameActions.Multiplayer; using MegaCrit.Sts2.Core.Hooks; @@ -40,6 +41,34 @@ public static AttackCommand FromCardCompatibility(this AttackCommand command, Ca [typeof(CardModel)], [0]) ); + + public static Task SignalPlayerChoiceBegunCompatibility(this PlayerChoiceContext context, Player player, + PlayerChoiceOptions options) + { + return _signalPlayerChoiceBegun.Invoke(context, player, options)!; + } + private static VariableMethod _signalPlayerChoiceBegun = new( + (typeof(PlayerChoiceContext), "SignalPlayerChoiceBegun", + [typeof(Player), typeof(PlayerChoiceOptions)], + [0, 1]), + (typeof(PlayerChoiceContext), "SignalPlayerChoiceBegun", + [typeof(PlayerChoiceOptions)], + [1]) + ); + + public static void CacheSavedProperties(Type t) + { + _injectSavedPropertiesType.Invoke(null, t); + } + + private static VariableMethod _injectSavedPropertiesType = new( + ("MegaCrit.Sts2.Core.Saves.Runs.SavedPropertiesTypeCache", "CachePropertiesForType", + [typeof(Type)], + [0]), + ("MegaCrit.Sts2.Core.Multiplayer.Serialization.ModelIdSerializationCache", "CachePropertiesForType", + [typeof(Type), null, null], + [0]) + ); public static class AttackCommand_ @@ -304,6 +333,7 @@ public class VariableMethod private readonly Dictionary _genericCalls = []; private readonly int[] _paramIndicies; + private readonly int _requiredParamCount; public int ParamCount => _paramIndicies.Length; @@ -325,6 +355,7 @@ public VariableMethod(params (Type?, string, Type?[], int[], Func(object? instance, params object?[] args) { - var finalArgs = new object?[_paramIndicies.Length]; - for (int i = 0; i < _paramIndicies.Length; ++i) + var finalArgs = new object?[_requiredParamCount]; + int i = 0; + for (; i < _paramIndicies.Length; ++i) finalArgs[i] = args[_paramIndicies[i]]; + for (; i < _requiredParamCount; ++i) + finalArgs[i] = null; return (T?) _method!.Invoke(instance, finalArgs); } @@ -363,9 +400,12 @@ public void Invoke(object? instance, params object?[] args) _genericCalls[typeof(TGeneric)] = method; } - var finalArgs = new object?[_paramIndicies.Length]; - for (int i = 0; i < _paramIndicies.Length; ++i) + var finalArgs = new object?[_requiredParamCount]; + int i = 0; + for (; i < _paramIndicies.Length; ++i) finalArgs[i] = args[_paramIndicies[i]]; + for (; i < _requiredParamCount; ++i) + finalArgs[i] = null; return (TReturn?) method.Invoke(instance, finalArgs); } @@ -377,9 +417,12 @@ public void InvokeGeneric(object? instance, params object?[] args) _genericCalls[typeof(TGeneric)] = method; } - var finalArgs = new object?[_paramIndicies.Length]; - for (int i = 0; i < _paramIndicies.Length; ++i) + var finalArgs = new object?[_requiredParamCount]; + int i = 0; + for (; i < _paramIndicies.Length; ++i) finalArgs[i] = args[_paramIndicies[i]]; + for (; i < _requiredParamCount; ++i) + finalArgs[i] = null; method.Invoke(instance, finalArgs); } diff --git a/Utils/DynamicVarSource.cs b/Utils/DynamicVarSource.cs index 0301dfe1..4a1f191b 100644 --- a/Utils/DynamicVarSource.cs +++ b/Utils/DynamicVarSource.cs @@ -21,7 +21,6 @@ public sealed class DynamicVarSource() public PowerModel? Power { get; init; } - public static implicit operator DynamicVarSource(CardModel card) { return new DynamicVarSource diff --git a/Utils/HookUtils.cs b/Utils/HookUtils.cs new file mode 100644 index 00000000..0cc71341 --- /dev/null +++ b/Utils/HookUtils.cs @@ -0,0 +1,298 @@ +using MegaCrit.Sts2.Core.Combat; +using MegaCrit.Sts2.Core.Entities.Multiplayer; +using MegaCrit.Sts2.Core.Entities.Players; +using MegaCrit.Sts2.Core.GameActions.Multiplayer; +using MegaCrit.Sts2.Core.Hooks; +using MegaCrit.Sts2.Core.Models; + +namespace BaseLib.Utils; + +/// +/// Provides utility methods for dispatching and aggregating combat hook events +/// across all hook listeners. +/// +/// Hook interfaces should be implemented on subclasses +/// to be picked up by the listeners. +/// +public static class HookUtils +{ + /// + /// Dispatches an action to all hook listeners of type . + /// No-op when is (outside combat). + /// + /// The hook interface to filter listeners by. + /// The current combat state to iterate listeners from. + /// The async action to invoke on each matching listener. + public static async Task Dispatch(ICombatState? combatState, Func action) + where THook : class + { + if (combatState == null) return; + foreach (var model in Hook.IterateCombatHookListeners(combatState).OfType()) + await action(model); + } + + /// + /// Dispatches an action to all combat hook listeners of type , + /// pushing and popping each listener onto the provided . + /// Silently skips listeners that are not instances. + /// + /// No-op when is . Unlike + /// , does not raise + /// — callers or listeners own that. + /// + /// + /// The hook interface to filter listeners by. + /// The current combat state to iterate listeners from. + /// The player choice context to push/pop each model onto. + /// The async action to invoke on each matching listener. + public static async Task Dispatch(ICombatState? combatState, PlayerChoiceContext ctx, + Func action) + where THook : class + { + if (combatState == null) return; + foreach (var model in Hook.IterateCombatHookListeners(combatState).OfType()) + { + if (model is not AbstractModel abstractModel) continue; + ctx.PushModel(abstractModel); + await action(model); + ctx.PopModel(abstractModel); + } + } + + /// + /// Dispatches an action to all hook listeners of type in + /// 's combat state, creating a + /// for each listener and awaiting its completion + /// or pause. Silently skips listeners that are not instances. + /// + /// No-op when the player has no combat state (outside combat). Each context is + /// attributed to 's NetId — the acting player, not + /// necessarily the local client. + /// is raised for each listener after its action completes; listeners should not raise + /// it themselves. + /// + /// + /// The hook interface to filter listeners by. + /// + /// The player whose combat state supplies the listeners and whose identity the created + /// contexts run under. + /// + /// + /// The async action to invoke on each matching listener, receiving that listener's + /// . + /// + public static async Task DispatchWithContext(Player player, + Func action) + where THook : class + { + var combatState = player.Creature.CombatState; + if (combatState == null) return; + var netId = player.NetId; + foreach (var model in Hook.IterateCombatHookListeners(combatState).OfType()) + { + if (model is not AbstractModel abstractModel) continue; + var hookCtx = new HookPlayerChoiceContext(abstractModel, netId, combatState, GameActionType.Combat); + var task = action(model, hookCtx); + await hookCtx.AssignTaskAndWaitForPauseOrCompletion(task); + abstractModel.InvokeExecutionFinished(); + } + } + + /// + /// Aggregates a value across all hook listeners of type , + /// passing each listener and the current accumulated value to the provided function. + /// + /// The hook interface to filter listeners by. + /// The type of the accumulated result. + /// The current combat state to iterate listeners from. + /// The initial value for the aggregation. + /// A function that takes a listener and the current value and returns the new value. + /// The final aggregated value after all listeners have been processed. + public static TResult Aggregate(ICombatState combatState, TResult initial, + Func action) + where THook : class + { + return Hook.IterateCombatHookListeners(combatState).OfType() + .Aggregate(initial, (current, model) => action(model, current)); + } + + /// + /// Returns if all hook listeners of type + /// satisfy the given predicate. + /// + /// The hook interface to filter listeners by. + /// The current combat state to iterate listeners from. + /// The condition to test each listener against. + public static bool All(ICombatState combatState, Func predicate) + where THook : class + { + return Hook.IterateCombatHookListeners(combatState).OfType().All(predicate); + } + + /// + /// Returns if all hook listeners of type + /// satisfy the given predicate, additionally collecting the listeners that failed it. + /// Vacuously when no listeners of the type exist. + /// + /// The hook interface to filter listeners by. + /// The current combat state to iterate listeners from. + /// The condition to test each listener against. + /// + /// The listeners that did not satisfy the predicate; empty when the result is + /// . + /// + public static bool All(ICombatState combatState, Func predicate, + out IEnumerable nonMatches) + where THook : class + { + var list = Hook.IterateCombatHookListeners(combatState).OfType().Where(m => !predicate(m)).ToList(); + nonMatches = list; + return list.Count == 0; + } + + /// + /// Returns if any hook listener of type + /// satisfies the given predicate. + /// + /// The hook interface to filter listeners by. + /// The current combat state to iterate listeners from. + /// The condition to test each listener against. + public static bool Any(ICombatState combatState, Func predicate) + where THook : class + { + return Hook.IterateCombatHookListeners(combatState).OfType().Any(predicate); + } + + /// + /// Returns if any hook listener of type + /// satisfies the given predicate, additionally collecting all listeners that matched. + /// Unlike LINQ Any, this does not short-circuit — the predicate runs for every listener. + /// + /// The hook interface to filter listeners by. + /// The current combat state to iterate listeners from. + /// The condition to test each listener against. + /// + /// All listeners that satisfied the predicate; empty when the result is + /// . + /// + public static bool Any(ICombatState combatState, Func predicate, + out IEnumerable matches) + where THook : class + { + var list = Hook.IterateCombatHookListeners(combatState).OfType().Where(predicate).ToList(); + matches = list; + return list.Count > 0; + } + + /// + /// Passes a value through all hook listeners of type , + /// tracking which listeners changed it. + /// + /// 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. + /// A function that takes a listener and the current value and returns the modified value. + /// + /// 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 Modify( + ICombatState? combatState, + TValue originalAmount, + Func amountModifier, + out IEnumerable modifiers) + where THook : class + where TValue : IEquatable + { + if (combatState == null) + { + modifiers = []; + return originalAmount; + } + var amount = originalAmount; + var abstractModelList = new List(); + foreach (var model in Hook.IterateCombatHookListeners(combatState).OfType()) + { + var previous = amount; + amount = amountModifier.Invoke(model, amount); + if (!previous.Equals(amount)) + abstractModelList.Add(model); + } + + modifiers = abstractModelList; + return amount; + } + + /// + /// Invokes a follow-up action on the listeners that previously modified a value via + /// , iterating in current hook-listener order (not the + /// order of ). Listeners no longer present in the combat + /// state's iteration are silently skipped. + /// is raised after each action for + /// listeners that are instances; implementations should not + /// raise it themselves. + /// + /// The hook interface to filter listeners by. + /// The current combat state to iterate listeners from. + /// + /// The set of listeners that modified the value, as returned by + /// . + /// + /// The async action to invoke on each modifier. + public static async Task AfterModifying(ICombatState cs, IEnumerable modifiers, + Func action) + where THook : class + { + var modifierSet = new HashSet(modifiers); + foreach (var iterateHookListener in Hook.IterateCombatHookListeners(cs).OfType()) + { + if (!modifierSet.Contains(iterateHookListener)) continue; + await action(iterateHookListener); + if (iterateHookListener is AbstractModel model) + model.InvokeExecutionFinished(); + } + } + + /// + /// Presents a mutable to all hook listeners of type + /// for in-place modification. Every listener is invoked + /// exactly once (the enumeration is fully materialized); each returns + /// to declare that it modified the value. + /// + /// Unlike , modification tracking is + /// self-reported — nothing verifies the value actually changed, so listeners + /// must return honestly or the follow-up will + /// target the wrong set. The same instance is returned; + /// it is passed back for call-site fluency, not copied. + /// + /// + /// The hook interface to filter listeners by. + /// The mutable type being modified in place (typically a class or collection). + /// The current combat state to iterate listeners from. + /// The instance listeners may mutate. + /// + /// Invoked per listener with the shared instance; returns whether this listener modified it. + /// + /// The listeners that reported modifying the value. + /// The same instance, after all listeners ran. + public static TValue ModifyMutable( + ICombatState combatState, + TValue value, + Func amountModifier, + out IEnumerable modifiers) + where THook : class + { + var list = Hook.IterateCombatHookListeners(combatState).OfType() + .Where(model => amountModifier.Invoke(model, value)).ToList(); + modifiers = list; + return value; + } +} \ No newline at end of file diff --git a/Utils/NodeFactories/NodeFactory.cs b/Utils/NodeFactories/NodeFactory.cs index 00c5b4c2..b3364923 100644 --- a/Utils/NodeFactories/NodeFactory.cs +++ b/Utils/NodeFactories/NodeFactory.cs @@ -12,6 +12,13 @@ namespace BaseLib.Utils.NodeFactories; /// public abstract class NodeFactory { + protected static readonly SpireField CreatedFromFactoryInternal = new(() => false); + /// + /// Returns true for any node created through a factory. + /// Will not be true for all nodes in the scene, only the root node of the generated scene. + /// + public static bool CreatedFromFactory(Node n) => CreatedFromFactoryInternal.Get(n); + public static void Init() { new ControlFactory(); @@ -332,6 +339,7 @@ public static T CreateFromResource(object resource) BaseLibMain.Logger.Info($"Creating {typeof(T).Name} from resource {resource.GetType().Name}"); var n = _instance.CreateBareFromResource(resource); _instance.ConvertScene(n, null); + CreatedFromFactoryInternal[n] = true; return n; } @@ -363,7 +371,9 @@ public static T CreateFromScene(PackedScene scene) } BaseLibMain.Logger.Info($"Creating {typeof(T).Name} from scene {scene.ResourcePath}"); - return _instance.CreateFromNode(scene.Instantiate()); + var result = _instance.CreateFromNode(scene.Instantiate()); + CreatedFromFactoryInternal[result] = true; + return result; } protected override T CreateFromNode(Node n) diff --git a/Utils/SavePatchUtils.cs b/Utils/SavePatchUtils.cs index a722a7da..04f3e5dc 100644 --- a/Utils/SavePatchUtils.cs +++ b/Utils/SavePatchUtils.cs @@ -148,6 +148,91 @@ static SavePatchUtils() SerializerDeserializerInfo.Serializer = (val, writer) => writer.WriteFullModelId(val); SerializerDeserializerInfo.Deserializer = reader => reader.ReadFullModelId(); + + //Basic arrays + SerializerDeserializerInfo.Serializer = (arr, writer) => + { + writer.WriteInt(arr.Length); + foreach (var val in arr) + { + writer.WriteInt(val); + } + }; + SerializerDeserializerInfo.Deserializer = reader => + { + var length = reader.ReadInt(); + var arr = new int[length]; + for (var i = 0; i < length; ++i) + arr[i] = reader.ReadInt(); + return arr; + }; + SerializerDeserializerInfo.Serializer = (arr, writer) => + { + writer.WriteInt(arr.Length); + foreach (var val in arr) + { + writer.WriteFloat(val); + } + }; + SerializerDeserializerInfo.Deserializer = reader => + { + var length = reader.ReadInt(); + var arr = new float[length]; + for (var i = 0; i < length; ++i) + arr[i] = reader.ReadFloat(); + return arr; + }; + SerializerDeserializerInfo.Serializer = (arr, writer) => + { + writer.WriteInt(arr.Length); + foreach (var val in arr) + { + writer.WriteDouble(val); + } + }; + SerializerDeserializerInfo.Deserializer = reader => + { + var length = reader.ReadInt(); + var arr = new double[length]; + for (var i = 0; i < length; ++i) + arr[i] = reader.ReadDouble(); + return arr; + }; + SerializerDeserializerInfo.Serializer = (arr, writer) => + { + writer.WriteInt(arr.Length); + foreach (var val in arr) + { + writer.WriteBool(val); + } + }; + SerializerDeserializerInfo.Deserializer = reader => + { + var length = reader.ReadInt(); + var arr = new bool[length]; + for (var i = 0; i < length; ++i) + arr[i] = reader.ReadBool(); + return arr; + }; + SerializerDeserializerInfo.Serializer = (arr, writer) => + { + writer.WriteInt(arr.Length); + foreach (var val in arr) + { + writer.Write(val); + } + }; + SerializerDeserializerInfo.Deserializer = reader => + { + var length = reader.ReadInt(); + var arr = new SerializableCard[length]; + for (var i = 0; i < length; ++i) + { + arr[i] = reader.Read(); + } + + return arr; + }; } } diff --git a/Utils/SpireField.cs b/Utils/SpireField.cs index e05a9393..91f04136 100644 --- a/Utils/SpireField.cs +++ b/Utils/SpireField.cs @@ -415,7 +415,7 @@ public bool RegisterCustomSave() } else if (!SavePatchUtils.TryGetSerializerDeserializer(out serializer, out deserializer)) { - BaseLibMain.Logger.Error($"Unable to register custom save for SavedSpireField {Name}; no serialization defined for" + + BaseLibMain.Logger.Error($"Unable to register custom save for SavedSpireField {Name}; no serialization defined for " + $"type {typeof(TVal).Name}. Set Serializer/Deserializer properties of SavedSpireField."); return false; } diff --git a/Utils/WhatMod.cs b/Utils/WhatMod.cs new file mode 100644 index 00000000..13d4a935 --- /dev/null +++ b/Utils/WhatMod.cs @@ -0,0 +1,124 @@ +using System.Reflection; +using BaseLib.Config; +using BaseLib.Extensions; +using HarmonyLib; +using MegaCrit.Sts2.Core.Helpers; +using MegaCrit.Sts2.Core.Modding; + +namespace BaseLib.Utils; + +/// +/// Maps modded content back to the mod that added it, by the assembly its type was defined in, +/// so tooltips can show "which mod is this from?". +/// +public static class WhatMod +{ + private static readonly FieldInfo? AssemblyField = AccessTools.DeclaredField(typeof(Mod), "assembly"); + private static readonly FieldInfo? AssembliesField = AccessTools.DeclaredField(typeof(Mod), "assemblies"); + + private static IReadOnlyList _loadedMods = []; + private static bool _built; + + private static readonly Assembly BasegameAssembly = typeof(Really).Assembly; + private static readonly Dictionary> AssembliesByMod = []; + private static readonly Dictionary ModByAssembly = []; + private static readonly Dictionary ModByType = []; + + public static List AssembliesForMod(Mod mod) => AssembliesByMod.GetValueOrDefault(mod, []); + + internal static void BuildAfterInit() + { + if (_built) return; + _built = true; + + _loadedMods = ModManager.GetLoadedMods().ToList(); + foreach (var mod in _loadedMods) + { + CheckAssembly(mod); + } + } + + private static void CheckAssembly(Mod mod) + { + List? modAssemblies = null; + if (AssemblyField != null) + { + var assembly = (Assembly?) AssemblyField.GetValue(mod); + if (assembly != null) + { + modAssemblies = [assembly]; + } + } + else if (AssembliesField != null) + { + var assemblies = (List?) AssembliesField.GetValue(mod); + if (assemblies != null) + { + AssembliesByMod[mod] = [..assemblies]; + } + } + else + { + BaseLibMain.Logger.Warn("Unable to find assemblies tied to mods."); + } + + if (modAssemblies == null) return; + + AssembliesByMod[mod] = modAssemblies; + foreach (var modAssembly in modAssemblies) + { + ModByAssembly[modAssembly] = mod; + } + } + + + /// + /// The mod that defined , or null if it belongs to the base game or mod map has not been built. + /// + public static Mod? FindMod(Type type) + { + if (!_built) return null; + if (type.Assembly.Equals(BasegameAssembly)) return null; + if (ModByType.TryGetValue(type, out var cached)) return cached; + + if (!ModByAssembly.TryGetValue(type.Assembly, out var mod)) + { + // Assembly not registered directly (e.g. bundled); fall back to matching the content's + // root namespace against a loaded mod's id. + var root = type.GetRootNamespace(); + mod = _loadedMods.FirstOrDefault(m => + m.manifest?.id != null && m.manifest.id.Equals(root, StringComparison.OrdinalIgnoreCase)); + } + + ModByType[type] = mod; + return mod; + } + + /// + /// Display name of the mod that defined type T, or null if it is base-game content. + /// Matches the installed-mods list: the manifest name, with the id in parentheses when it differs. + /// + public static string? FindModName() + { + return FindModName(typeof(T)); + } + + /// + /// Display name of the mod that defined , or null if it is base-game content. + /// Matches the installed-mods list: the manifest name, with the id in parentheses when it differs. + /// + public static string? FindModName(Type type) + { + if (type.Assembly.Equals(BasegameAssembly)) return null; + + var mod = FindMod(type); + var name = mod?.manifest?.name; + var id = mod?.manifest?.id ?? type.GetRootNamespace(); + + if (string.IsNullOrWhiteSpace(name)) + return id; + if (string.IsNullOrWhiteSpace(id) || id.Equals(name, StringComparison.OrdinalIgnoreCase)) + return name; + return BaseLibConfig.IncludeModId ? $"{name} ({id})" : name; + } +} From d6e4d78f37efbd823a732897c59147d6c2a2a140 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:32:08 +0200 Subject: [PATCH 8/9] Center chain image --- BaseLib/scenes/linked_reward_set.tscn | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BaseLib/scenes/linked_reward_set.tscn b/BaseLib/scenes/linked_reward_set.tscn index ca0b662d..89099b52 100644 --- a/BaseLib/scenes/linked_reward_set.tscn +++ b/BaseLib/scenes/linked_reward_set.tscn @@ -36,9 +36,9 @@ anchors_preset = 13 anchor_left = 0.5 anchor_right = 0.5 anchor_bottom = 1.0 -offset_left = -41.0 +offset_left = -66.0 offset_top = 48.0 -offset_right = 59.0 +offset_right = 34.0 offset_bottom = 11.0 grow_horizontal = 2 grow_vertical = 2 From 98fd191399febf3f408c9c7db9ebd99deec90f18 Mon Sep 17 00:00:00 2001 From: quipsol <245758987+quipsol@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:37:03 +0200 Subject: [PATCH 9/9] Fix critical CardReward issue card rewards where auto accepting the set on skip press --- BaseLibScenes/NCustomLinkedRewardSet.cs | 68 +++--- ....cs => CustomLinkedRewardChoiceMessage.cs} | 4 +- .../LinkedRewardSet/CustomLinkedRewardSet.cs | 220 ++++++++++-------- 3 files changed, 156 insertions(+), 136 deletions(-) rename Common/Rewards/LinkedRewardSet/{ExclusiveLinkedRewardChoiceMessage.cs => CustomLinkedRewardChoiceMessage.cs} (92%) diff --git a/BaseLibScenes/NCustomLinkedRewardSet.cs b/BaseLibScenes/NCustomLinkedRewardSet.cs index d8ba6a4d..d9bd480b 100644 --- a/BaseLibScenes/NCustomLinkedRewardSet.cs +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -30,35 +30,44 @@ public partial class NCustomLinkedRewardSet : Control public static AddedNode NHighlights = new((rewardButton) => { var textureRec = new NRewardHighlight(); + // I am using the card glow texture and morph it into something that fits. + // If need be, we can add our own glow-reward-button hdr texture. textureRec.Texture = PreloadManager.Cache.GetCompressedTexture2D("res://images/packed/card_template/card_frame_sdf.exr"); rewardButton.AddChildSafely(textureRec); rewardButton.MoveChild(textureRec, rewardButton.GetNode("%Background").GetIndex()); + textureRec.Modulate = new Color("00f4fcfa"); + textureRec.MouseFilter = MouseFilterEnum.Ignore; textureRec.ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize; textureRec.StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered; - textureRec.Modulate = new Color("00f4fcfa"); textureRec.Scale = new Vector2(1.8f, 0.3f); textureRec.Position = new Vector2(-270f, -33f); - textureRec.MouseFilter = MouseFilterEnum.Ignore; return textureRec; }); - private NRewardsScreen _rewardsScreen; - private Control _rewardContainer; - private Control _chainsContainer; - - public CustomLinkedRewardSet CustomLinkedRewardSet { get; private set; } - // We are using our own scene because we don't know how/when MegaCrit modifies theirs private static string ScenePath => $"res://BaseLib/scenes/linked_reward_set.tscn"; private static string ChainImagePath => ImageHelper.GetImagePath("/ui/reward_screen/reward_chain.png"); // The game groups some assets in AssetSets which exists to not clutter PreloadManager. We aren't using it yet. // public static IEnumerable AssetPaths => [ScenePath, ChainImagePath]; - + + private NRewardsScreen _rewardsScreen; + private Control _rewardContainer; + private Control _chainsContainer; private bool _signalAlreadyReceived = false; private readonly List _rewardButtons = []; + public CustomLinkedRewardSet CustomLinkedRewardSet { get; private set; } + + + public static NCustomLinkedRewardSet Create(CustomLinkedRewardSet linkedReward, NRewardsScreen screen) + { + var nCustomLinkedRewardSet = PreloadManager.Cache.GetScene(ScenePath).Instantiate(PackedScene.GenEditState.Disabled); + nCustomLinkedRewardSet._rewardsScreen = screen; + nCustomLinkedRewardSet.SetReward(linkedReward); + return nCustomLinkedRewardSet; + } public override void _Ready() { @@ -74,14 +83,7 @@ public override void _ExitTree() NRewardButtonEvents.Focused -= OnFocused; NRewardButtonEvents.Unfocused -= OnUnfocused; } - - public static NCustomLinkedRewardSet Create(CustomLinkedRewardSet linkedReward, NRewardsScreen screen) - { - var nCustomLinkedRewardSet = PreloadManager.Cache.GetScene(ScenePath).Instantiate(PackedScene.GenEditState.Disabled); - nCustomLinkedRewardSet._rewardsScreen = screen; - nCustomLinkedRewardSet.SetReward(linkedReward); - return nCustomLinkedRewardSet; - } + private void SetReward(CustomLinkedRewardSet linkedReward) { @@ -163,7 +165,6 @@ private void GetReward(NRewardButton button) rewardButton.GetReward(); } } - CustomLinkedRewardSet.OnSkipped(); EmitSignal(RewardClaimedSignalName, this); this.QueueFreeSafely(); } @@ -204,6 +205,9 @@ private static void OnUnfocusEvent(NRewardButton __instance) [HarmonyPatch] public static class NCustomLinkedRewardSetPatches { + + // roughly at line 190 - 205 in the decompiled code + // Add support for NCustomLinkedRewardSet [HarmonyTranspiler] [HarmonyPatch(typeof(NRewardsScreen), "_Ready")] private static IEnumerable ReplaceConnectMethodWithCustom(IEnumerable instructions, MethodBase originalMethod) @@ -214,32 +218,26 @@ private static IEnumerable ReplaceConnectMethodWithCustom(IEnum var matcher = new CodeMatcher(instructions) .MatchStartForward(new CodeMatch(OpCodes.Call, nRewardButtonCreate)) .ThrowIfInvalid("Could not find NRewardButton.Create call (else-branch start)"); - - - matcher.Advance(-3); // Step back to the else-branch's real first instruction: ldloc.s V_7 - - var optionField = (FieldInfo)matcher.InstructionAt(4).operand; // grab the real field - var endLabel = matcher.InstructionAt(-1).operand; // the preceding br.s's target = IL_037f + matcher.Advance(-3); // Step back to the else-branch's real first instruction + var optionField = (FieldInfo)matcher.InstructionAt(4).operand; + var endLabel = matcher.InstructionAt(-1).operand; // This instruction is the actual jump target for the `if` check (item is LinkedRewardSet == false). // Detach its label so we can move it onto our own first instruction instead. var elseEntryLabels = new List(matcher.Labels); matcher.Labels.Clear(); - var loadDisplayClass = matcher.Instruction.Clone().WithLabels(elseEntryLabels); // ldloc.s V_7 (now the real entry point) + var loadDisplayClass = matcher.Instruction.Clone().WithLabels(elseEntryLabels); // ldloc.s (now the real entry point) var loadRewardItem = matcher.InstructionAt(1).Clone(); // ldloc.s reward - var newInstructions = new List - { - loadDisplayClass, // push V_7 - new CodeInstruction(OpCodes.Ldflda, optionField), // push &V_7.option - loadRewardItem, // push reward - new CodeInstruction(OpCodes.Ldarg_0), // push this + matcher.Insert([ + loadDisplayClass, + new CodeInstruction(OpCodes.Ldflda, optionField), + loadRewardItem, + new CodeInstruction(OpCodes.Ldarg_0), // this new CodeInstruction(OpCodes.Call, customLinkedRewardSetCheck), - new CodeInstruction(OpCodes.Brtrue, endLabel), // handled -> skip the rest of the else-branch - }; - - matcher.Insert(newInstructions); // insert before the (now unlabeled) original else-branch code + new CodeInstruction(OpCodes.Brtrue, endLabel), // reward handled -> skip the rest of the else-branch + ]); // insert before the (now unlabeled) original else-branch code return matcher.InstructionEnumeration(); } diff --git a/Common/Rewards/LinkedRewardSet/ExclusiveLinkedRewardChoiceMessage.cs b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs similarity index 92% rename from Common/Rewards/LinkedRewardSet/ExclusiveLinkedRewardChoiceMessage.cs rename to Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs index d6cf6b19..abdfa688 100644 --- a/Common/Rewards/LinkedRewardSet/ExclusiveLinkedRewardChoiceMessage.cs +++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs @@ -5,7 +5,7 @@ namespace BaseLib.Common.Rewards.LinkedRewardSet; -public class ExclusiveLinkedRewardChoiceMessage : ICustomMessage +public class CustomLinkedRewardChoiceMessage : ICustomMessage { public int setId; public int containerIndex; @@ -45,7 +45,7 @@ public void HandleMessage(ulong senderId) if (set.Rewards[containerIndex] is not CustomLinkedRewardSet container) return; if (nestedIndex < 0 || nestedIndex >= container.Rewards.Count) return; - container.SetPendingExclusiveSelection(container.Rewards[nestedIndex]); + container.SetPendingSelection(container.Rewards[nestedIndex]); TaskHelper.RunSafely(synchronizer.SelectRewardForPlayer(rewardsSetState, container)); } } \ No newline at end of file diff --git a/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs index 83735443..4e768938 100644 --- a/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs +++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs @@ -1,5 +1,7 @@ -using System.Diagnostics.CodeAnalysis; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using BaseLib.Abstracts; +using BaseLib.BaseLibScenes; using BaseLib.Patches.Content; using BaseLib.Patches.Saves; using BaseLib.Utils; @@ -28,15 +30,8 @@ public class CustomLinkedRewardSet : CustomReward [CustomEnum] public static RewardType CustomLinkedRewardType; private const string SaveId = "baselib_customlinkedrewardset_children"; - private LocString HoverTipTitle => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled - ? "BASELIB-BUNDLED_REWARDS.title" : "BASELIB-EXCLUSIVE_REWARDS.title"); - private LocString HoverTipDesc => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled - ? "BASELIB-BUNDLED_REWARDS.description" : "BASELIB-EXCLUSIVE_REWARDS.description"); - - public HoverTip HoverTip => new(HoverTipTitle, HoverTipDesc); private static readonly SpireField ParentCustomLinkedRewardSet = new(() => null); - public static bool TryGetCustomLinkedRewardSet(Reward reward, [NotNullWhen(true)] out CustomLinkedRewardSet? customLinkedRewardSet) { customLinkedRewardSet = ParentCustomLinkedRewardSet.Get(reward); @@ -44,22 +39,25 @@ public static bool TryGetCustomLinkedRewardSet(Reward reward, [NotNullWhen(true) } + private LocString HoverTipTitle => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled + ? "BASELIB-BUNDLED_REWARDS.title" : "BASELIB-EXCLUSIVE_REWARDS.title"); + private LocString HoverTipDesc => new("static_hover_tips", LinkedRewardType == LinkedRewardType.Bundled + ? "BASELIB-BUNDLED_REWARDS.description" : "BASELIB-EXCLUSIVE_REWARDS.description"); + public HoverTip HoverTip => new(HoverTipTitle, HoverTipDesc); + public override LocString Description => new("gameplay_ui", "COMBAT_REWARD_LINKED"); + + private List _rewards; - public IReadOnlyList Rewards => _rewards.ToList(); + private LinkedRewardType _linkedRewardType; + private bool _selectionStarted = false; + private Reward? _pendingSelection; + public IReadOnlyList Rewards => _rewards.ToList(); protected override RewardType RewardType => CustomLinkedRewardType; + public LinkedRewardType LinkedRewardType => _linkedRewardType; public override int RewardsSetIndex => Rewards.Max(r => r.RewardsSetIndex); - public override CreateRewardFromSave DeserializeMethod => CreateFromSerializable; - public override LocString Description => new("gameplay_ui", "COMBAT_REWARD_LINKED"); public override bool IsPopulated => _rewards.All(r => r.IsPopulated); - private LinkedRewardType _linkedRewardType; - public LinkedRewardType LinkedRewardType => _linkedRewardType; - - private bool _selectionStarted = false; - - private Reward? _pendingExclusiveSelection; - public void SetPendingExclusiveSelection(Reward chosen) => _pendingExclusiveSelection = chosen; /// @@ -70,7 +68,6 @@ private CustomLinkedRewardSet() : base(null!) { _rewards = []; } - public CustomLinkedRewardSet(List rewards, Player player, LinkedRewardType linkedRewardType = LinkedRewardType.Exclusive) : base(player) { _rewards = rewards; @@ -79,74 +76,97 @@ public CustomLinkedRewardSet(List rewards, Player player, LinkedRewardTy ParentCustomLinkedRewardSet.Set(reward, this); } - - public override void Populate() { foreach (var reward in _rewards) reward.Populate(); } - + + public override void MarkContentAsSeen() + { + foreach (var reward in _rewards) + reward.MarkContentAsSeen(); + } + + public override void OnSkipped() + { + foreach (var reward in _rewards) + reward.OnSkipped(); + } protected override async Task OnSelect() { - if (_selectionStarted) return true; + if (_selectionStarted) return SuccessfullySelected; _selectionStarted = true; - - if (LinkedRewardType == LinkedRewardType.Exclusive) + return LinkedRewardType switch { - var chosen = _pendingExclusiveSelection; - _pendingExclusiveSelection = null; - if (chosen == null) - { - BaseLibMain.Logger.Error("No Reward selected. Should not happen!"); - return false; - } + LinkedRewardType.Exclusive => await OnSelectExclusive(), + LinkedRewardType.Bundled => await OnSelectBundled(), + _ => true + }; + } - if (!chosen.SuccessfullySelected) - await chosen.SelectUnsynchronized(); + private async Task OnSelectExclusive() + { + var chosen = _pendingSelection; + _pendingSelection = null; + if (chosen == null) + { + BaseLibMain.Logger.Error("No Reward selected. Should not happen!"); + _selectionStarted = false; + return false; + } - foreach (var reward in _rewards.ToList()) - { - if (reward != chosen && !reward.SuccessfullySelected) - reward.OnSkipped(); - } + if (!(chosen.SuccessfullySelected || await chosen.SelectUnsynchronized())) + { + _selectionStarted = false; + return false; } - if(LinkedRewardType == LinkedRewardType.Bundled) + foreach (var reward in _rewards.ToList().Where(reward => reward != chosen && !reward.SuccessfullySelected)) + reward.OnSkipped(); + return true; + } + private async Task OnSelectBundled() + { + var ordered = _rewards.ToList(); + var clicked = _pendingSelection; + _pendingSelection = null; + if (clicked != null && ordered.Remove(clicked)) // move the selected reward to the front, so it is offered first + ordered.Insert(0, clicked); + foreach (var reward in ordered) { - foreach (var reward in _rewards.ToList()) + if (reward.SuccessfullySelected) continue; + if (await reward.SelectUnsynchronized()) continue; + if (!_rewards.Any(r => r.SuccessfullySelected)) { - if (reward.SuccessfullySelected) continue; - await reward.SelectUnsynchronized(); // we do only want local machine! + // Nothing claimed yet: the player was only peeking, back out and keep the set available. + _selectionStarted = false; + return false; } + + // The bundle is already committed, so a skip is a final "I don't want any of these" (for this reward only). + // The remaining rewards are still offered before the set resolves. + reward.OnSkipped(); } - return true; } - - public override void MarkContentAsSeen() - { - foreach (var reward in _rewards) - reward.MarkContentAsSeen(); - - } - public override void OnSkipped() - { - foreach (var reward in _rewards) - reward.OnSkipped(); - - } - - public void RemoveReward(Reward reward) - { - _rewards.Remove(reward); - } + /// + /// is not being notified of this. + /// Using it during the reward screen can lead to undefined behaviour. + /// + /// + public void RemoveReward(Reward reward) => _rewards.Remove(reward); - - - + public override CreateRewardFromSave DeserializeMethod => CreateFromSerializable; + + /// + /// Exclusive: the reward the player chose. + /// Bundled: the reward whose button was clicked (player should see that one first to not confuse them) + /// + public void SetPendingSelection(Reward chosen) => _pendingSelection = chosen; + public static CustomReward CreateFromSerializable(SerializableReward save, Player player) => new CustomLinkedRewardSet([], player); @@ -161,16 +181,16 @@ private void RestoreRewards(List rewards, LinkedRewardType linkedRewardT public override void Initialize() { base.Initialize(); - ExtendedSaveTypes.RegisterObjectSaveType( - ExtendedSaveTypes.PropertyFunc>( - nameof(SerializableCustomLinkedRewardData.Rewards)), - ExtendedSaveTypes.PropertyFunc( - nameof(SerializableCustomLinkedRewardData.LinkedRewardTypeValue)) + ExtendedSaveTypes.RegisterObjectSaveType( + ExtendedSaveTypes.PropertyFunc>( + nameof(SerializableCustomLinkedRewardSet.Rewards)), + ExtendedSaveTypes.PropertyFunc( + nameof(SerializableCustomLinkedRewardSet.LinkedRewardTypeValue)) ); - ExtendedSaveHandlers.RegisterSave( + ExtendedSaveHandlers.RegisterSave( SaveId, reward => reward is CustomLinkedRewardSet clrs - ? new SerializableCustomLinkedRewardData + ? new SerializableCustomLinkedRewardSet { Rewards = clrs.Rewards.Select(r => r.ToSerializable()).ToList(), LinkedRewardTypeValue = (int)clrs.LinkedRewardType @@ -185,7 +205,7 @@ public override void Initialize() } } -public class SerializableCustomLinkedRewardData : IPacketSerializable +public class SerializableCustomLinkedRewardSet : IPacketSerializable { public List Rewards { get; set; } = []; public int LinkedRewardTypeValue { get; set; } @@ -205,7 +225,7 @@ public void Deserialize(PacketReader reader) } [HarmonyPatch] -public static class CustomLinkedRewardSetPatches +public static class CustomLinkedRewardSet_Patches { [HarmonyPatch(typeof(NRewardButton), "SetReward")] [HarmonyPrefix] @@ -228,44 +248,46 @@ private static void AddHoverTip(Reward __instance, ref IEnumerable __ [HarmonyPatch] -public static class CustomLinkedRewardSetMultiplayerPatches +public static class CustomLinkedRewardSet_MultiplayerPatches { [HarmonyPatch(typeof(RewardsSetSynchronizer), nameof(RewardsSetSynchronizer.SelectLocalReward))] - static class RedirectBundledNestedRewardSelection + private static class RedirectNestedRewardSelection { // index must be top level reward, so we reroute to linked container [HarmonyPrefix] static bool Prefix(RewardsSetSynchronizer __instance, ref Task __result, ref Reward reward) { if (!CustomLinkedRewardSet.TryGetCustomLinkedRewardSet(reward, out var parent)) return true; - - if(parent.LinkedRewardType == LinkedRewardType.Bundled) + if (parent.SuccessfullySelected) { - reward = parent; - return true; + // Bundled sibling buttons re-enter here via NCustomLinkedRewardSet.GetReward after the set + // already completed. + // Report success without reselecting. + __result = Task.FromResult(true); + return false; } - // We can not use the existing Synchronisation method for Exclusive Linked rewards. + // We can not use the existing Synchronisation method for Linked rewards. // The game only sends the index of the reward, no information about anything else. - // Use custom message to bypass base games RewardSetSynchronizer completely in this case. - if (parent.LinkedRewardType == LinkedRewardType.Exclusive) - { - var rewardStateForPlayer = __instance.GetRewardStateForPlayer(__instance.LocalPlayer); - var rewardsSetState = rewardStateForPlayer.rewardsStack.Last(); - var containerIndex = rewardsSetState.set.Rewards.IndexOf(parent); - var nestedIndex = parent.Rewards.ToList().IndexOf(reward); + // Exclusive needs to know which nested reward was chosen + // Bundled needs to know which nested reward to resolve first + // (nested CardReward choices sync in resolution order via PlayerChoiceSynchronizer, + // so all clients must resolve in the same order). + // Using a custom message to bypass base games RewardsSetSynchronizer completely. + var rewardStateForPlayer = __instance.GetRewardStateForPlayer(__instance.LocalPlayer); + var rewardsSetState = rewardStateForPlayer.rewardsStack.Last(); + var containerIndex = rewardsSetState.set.Rewards.IndexOf(parent); + var nestedIndex = parent.Rewards.ToList().IndexOf(reward); - parent.SetPendingExclusiveSelection(reward); - __result = __instance.SelectRewardForPlayer(rewardsSetState, parent); // apply locally immediately + parent.SetPendingSelection(reward); + __result = __instance.SelectRewardForPlayer(rewardsSetState, parent); // apply locally immediately - CustomMessageWrapper.Send(new ExclusiveLinkedRewardChoiceMessage - { - setId = rewardsSetState.set.Id, - containerIndex = containerIndex, - nestedIndex = nestedIndex - }); - return false; - } - return true; + CustomMessageWrapper.Send(new CustomLinkedRewardChoiceMessage + { + setId = rewardsSetState.set.Id, + containerIndex = containerIndex, + nestedIndex = nestedIndex + }); + return false; } } } \ No newline at end of file