diff --git a/BaseLib/localization/eng/static_hover_tips.json b/BaseLib/localization/eng/static_hover_tips.json index fe1ea886..8027737f 100644 --- a/BaseLib/localization/eng/static_hover_tips.json +++ b/BaseLib/localization/eng/static_hover_tips.json @@ -11,4 +11,8 @@ "BASELIB-SCRY.title": "Scry", "BASELIB-SCRY.description": "Look at the top [blue]X[/blue] cards of your [gold]Draw Pile[/gold]. You may discard any of them.", "BASELIB-SCRY.smartDescription": "Look at the top {Scry:plural:card|[blue]{Scry:diff()}[/blue] cards} of your [gold]Draw Pile[/gold]. You may discard {Scry:plural:it|any of them}." + "BASELIB-BUNDLED_REWARDS.title": "Bundled Rewards", + "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/BaseLib/localization/jpn/static_hover_tips.json b/BaseLib/localization/jpn/static_hover_tips.json index 3868dba9..8de26fe6 100644 --- a/BaseLib/localization/jpn/static_hover_tips.json +++ b/BaseLib/localization/jpn/static_hover_tips.json @@ -11,4 +11,4 @@ "BASELIB-SCRY.title": "占術", "BASELIB-SCRY.description": "山札の上からカードを[blue]X[/blue]枚見て、その中から好きなだけ捨ててもよい。", "BASELIB-SCRY.smartDescription": "山札の上から{Scry:plural:カードを1枚|カードを[blue]{Scry:diff()}[/blue]枚}見て、{Scry:plural:それを捨ててもよい|その中から好きなだけ捨ててもよい}。" -} \ 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..89099b52 --- /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 = -66.0 +offset_top = 48.0 +offset_right = 34.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..d9bd480b --- /dev/null +++ b/BaseLibScenes/NCustomLinkedRewardSet.cs @@ -0,0 +1,253 @@ +using System.Reflection; +using System.Reflection.Emit; +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.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 +{ + public static readonly StringName RewardClaimedSignalName = "RewardClaimed"; + + [Signal] + public delegate void RewardClaimedEventHandler(NCustomLinkedRewardSet customLinkedRewardSet); + + /// + /// 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) => + { + 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.Scale = new Vector2(1.8f, 0.3f); + textureRec.Position = new Vector2(-270f, -33f); + return textureRec; + }); + + + // 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() + { + _rewardContainer = GetNode("%RewardContainer"); + _chainsContainer = GetNode("%ChainContainer"); + NRewardButtonEvents.Focused += OnFocused; + NRewardButtonEvents.Unfocused += OnUnfocused; + Reload(); + } + + public override void _ExitTree() + { + NRewardButtonEvents.Focused -= OnFocused; + NRewardButtonEvents.Unfocused -= OnUnfocused; + } + + + 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 * (-8 + i * (5f + 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 (_signalAlreadyReceived) return; + _signalAlreadyReceived = true; + _rewardButtons.Remove(button); + foreach (var rewardButton in _rewardButtons) + { + rewardButton.GetReward(); + } + } + 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 +{ + + // 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) + { + var customLinkedRewardSetCheck = AccessTools.Method(typeof(NCustomLinkedRewardSetPatches), nameof(CustomLinkedRewardSetCheck)); + var nRewardButtonCreate = AccessTools.Method(typeof(NRewardButton), nameof(NRewardButton.Create)); + + 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 + 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 (now the real entry point) + var loadRewardItem = matcher.InstructionAt(1).Clone(); // ldloc.s reward + + 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), // reward handled -> skip the rest of the else-branch + ]); // 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; + } +} 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); + } +} diff --git a/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.cs new file mode 100644 index 00000000..abdfa688 --- /dev/null +++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardChoiceMessage.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 CustomLinkedRewardChoiceMessage : 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.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 new file mode 100644 index 00000000..4e768938 --- /dev/null +++ b/Common/Rewards/LinkedRewardSet/CustomLinkedRewardSet.cs @@ -0,0 +1,293 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using BaseLib.Abstracts; +using BaseLib.BaseLibScenes; +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.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.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 +/// +/// 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 +{ + [CustomEnum] public static RewardType CustomLinkedRewardType; + private const string SaveId = "baselib_customlinkedrewardset_children"; + + + 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 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; + 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 bool IsPopulated => _rewards.All(r => r.IsPopulated); + + + + /// + /// 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) + 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 SuccessfullySelected; + _selectionStarted = true; + return LinkedRewardType switch + { + LinkedRewardType.Exclusive => await OnSelectExclusive(), + LinkedRewardType.Bundled => await OnSelectBundled(), + _ => true + }; + } + + 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; + } + + if (!(chosen.SuccessfullySelected || await chosen.SelectUnsynchronized())) + { + _selectionStarted = false; + return false; + } + 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) + { + if (reward.SuccessfullySelected) continue; + if (await reward.SelectUnsynchronized()) continue; + if (!_rewards.Any(r => r.SuccessfullySelected)) + { + // 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; + } + + /// + /// 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); + + private void RestoreRewards(List rewards, LinkedRewardType linkedRewardType) + { + _rewards = rewards; + _linkedRewardType = linkedRewardType; + foreach (var reward in _rewards) + ParentCustomLinkedRewardSet.Set(reward, this); + } + + public override void Initialize() + { + base.Initialize(); + ExtendedSaveTypes.RegisterObjectSaveType( + ExtendedSaveTypes.PropertyFunc>( + nameof(SerializableCustomLinkedRewardSet.Rewards)), + ExtendedSaveTypes.PropertyFunc( + nameof(SerializableCustomLinkedRewardSet.LinkedRewardTypeValue)) + ); + ExtendedSaveHandlers.RegisterSave( + SaveId, + reward => reward is CustomLinkedRewardSet clrs + ? new SerializableCustomLinkedRewardSet + { + Rewards = clrs.Rewards.Select(r => r.ToSerializable()).ToList(), + LinkedRewardTypeValue = (int)clrs.LinkedRewardType + } + : null, + (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 class SerializableCustomLinkedRewardSet : IPacketSerializable +{ + public List Rewards { get; set; } = []; + public int LinkedRewardTypeValue { get; set; } + + + 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 CustomLinkedRewardSet_Patches +{ + [HarmonyPatch(typeof(NRewardButton), "SetReward")] + [HarmonyPrefix] + private static void ThrowIfMisuseOfCustomRewardSet(Reward reward) + { + if(reward is CustomLinkedRewardSet) + throw new ArgumentException("You aren't allowed to apply a CustomLinkedRewardSet to an NRewardButton"); + } + + [HarmonyPatch(typeof(Reward), "HoverTips", MethodType.Getter)] + [HarmonyPostfix] + private static void AddHoverTip(Reward __instance, ref IEnumerable __result) + { + if (!CustomLinkedRewardSet.TryGetCustomLinkedRewardSet(__instance, out var customLinkedRewardSet)) return; + var list = __result.ToList(); + list.Add(customLinkedRewardSet.HoverTip); + __result = list; + } +} + + +[HarmonyPatch] +public static class CustomLinkedRewardSet_MultiplayerPatches +{ + [HarmonyPatch(typeof(RewardsSetSynchronizer), nameof(RewardsSetSynchronizer.SelectLocalReward))] + 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.SuccessfullySelected) + { + // 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 Linked rewards. + // The game only sends the index of the reward, no information about anything else. + // 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.SetPendingSelection(reward); + __result = __instance.SelectRewardForPlayer(rewardsSetState, parent); // apply locally immediately + + CustomMessageWrapper.Send(new CustomLinkedRewardChoiceMessage + { + setId = rewardsSetState.set.Id, + containerIndex = containerIndex, + nestedIndex = nestedIndex + }); + return false; + } + } +} \ 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 +}