From 6f514e85b66e3ee1f66c2bf56033ae3d5d640926 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 23 Oct 2026 18:45:33 +0500 Subject: [PATCH] Let player choose rear-guard circle for call-from-soul abilities --- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 151 +++++++++++++++++- .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 55 +++++++ .../Engine/Abilities/CardScripts.cs | 5 +- .../Engine/Abilities/CardScriptsBT01.cs | 10 +- .../src/Vanguard.Rules/Engine/GameEngine.cs | 5 +- .../Vanguard.Rules.Tests/GameEngineTests.cs | 12 +- 6 files changed, 229 insertions(+), 9 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 33e6f9d..6b33187 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -71,6 +71,10 @@ namespace Vanguard.UI private Text _abilityOverlayName; private RectTransform _abilityOverlayButtons; private int _abilityPromptInstanceId; + private RectTransform _choiceOverlay; + private Text _choiceOverlayTitle; + private RectTransform _choiceOverlayButtons; + private string _choicePromptKey; private MatchConnectionInfo _info; private MatchView _view; private bool _connecting; @@ -231,6 +235,7 @@ namespace Vanguard.UI BuildPhasePanel(); BuildCombatStats(); BuildAbilityOverlay(); + BuildChoiceOverlay(); BuildReconnectOverlay(); _cardMenu = new CardActionMenuView(); _cardMenu.Build(_root); @@ -301,6 +306,7 @@ namespace Vanguard.UI _board3D?.SetVisible(true); _overlays?.CloseAll(); HideAbilityOverlay(); + HideChoiceOverlay(); _selfZones?.Update(null, self: true); _opponentZones?.Update(null, self: false); _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; @@ -333,6 +339,7 @@ namespace Vanguard.UI _skipFeedback.gameObject.SetActive(false); _overlays?.CloseAll(); HideAbilityOverlay(); + HideChoiceOverlay(); _cardMenu?.Hide(); _combatStats?.gameObject.SetActive(false); _board3D?.SetVisible(false); @@ -352,6 +359,7 @@ namespace Vanguard.UI UpdateFeedback(); SanitizeInputLocks(); SyncAbilityPrompt(); + SyncChoicePrompt(); UpdateAutoPhases(); } @@ -374,6 +382,8 @@ namespace Vanguard.UI _inputLock.Release("card-menu"); if (_abilityOverlay == null || !_abilityOverlay.gameObject.activeSelf) _inputLock.Release("ability"); + if (_choiceOverlay == null || !_choiceOverlay.gameObject.activeSelf) + _inputLock.Release("choice"); if (!_feedbackPlaying && _feedbackQueue.Count == 0) { _feedbackLockHeld = false; @@ -736,6 +746,7 @@ namespace Vanguard.UI Render(); SyncAbilityPrompt(); + SyncChoicePrompt(); } private void OnEvents(ServerEvents events) @@ -1287,6 +1298,115 @@ namespace Vanguard.UI _inputLock.Release("ability"); } + private void BuildChoiceOverlay() + { + _choiceOverlay = UiKit.CreatePanel( + _root, + "ChoiceOverlay", + new Color(0f, 0f, 0f, 0.72f)); + var card = UiKit.CreateCenteredCard( + _choiceOverlay, + "ChoiceCard", + new Vector2(760f, 300f), + Panel); + UiKit.AddVerticalLayout(card, 22, 10); + + var title = UiKit.CreateText( + card, + "ChoiceTitle", + "Выбор", + 22, + new Color(1f, 0.78f, 0.42f), + TextAnchor.MiddleCenter); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 34f); + + _choiceOverlayTitle = UiKit.CreateText( + card, + "ChoicePrompt", + string.Empty, + 18, + Color.white, + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(_choiceOverlayTitle, 52f); + + _choiceOverlayButtons = UiKit.CreatePanel( + card, "ChoiceButtons", Color.clear); + UiKit.AddHorizontalLayout(_choiceOverlayButtons, 0, 8); + UiKit.SetPreferredHeight(_choiceOverlayButtons, 52f); + _choiceOverlay.gameObject.SetActive(false); + } + + private bool IsRearGuardChoice() + { + var prompt = _view?.Choice?.Prompt; + return !string.IsNullOrEmpty(prompt) + && (prompt.IndexOf("круг", StringComparison.OrdinalIgnoreCase) >= 0 + || prompt.IndexOf("rear", StringComparison.OrdinalIgnoreCase) >= 0); + } + + private void SyncChoicePrompt() + { + if (_choiceOverlay == null || _sending) + return; + + var choice = _view?.Choice; + if (choice == null + || _view == null + || _view.ActivePlayer != _view.You + || !IsRearGuardChoice()) + { + HideChoiceOverlay(); + return; + } + + var key = (choice.Prompt ?? string.Empty) + "|" + + string.Join(",", choice.Options.Select(option => option.Slot)); + if (_choiceOverlay.gameObject.activeSelf + && string.Equals(_choicePromptKey, key, StringComparison.Ordinal)) + return; + + _choicePromptKey = key; + _choiceOverlayTitle.text = choice.Prompt ?? "Выберите круг"; + UiKit.MakeLiteral(_choiceOverlayTitle); + ClearChildren(_choiceOverlayButtons); + + for (var i = 0; i < choice.Options.Count; i++) + { + var index = i; + var option = choice.Options[i]; + var button = UiKit.CreateButton( + _choiceOverlayButtons, + "Choice_" + option.Slot, + string.IsNullOrWhiteSpace(option.Label) + ? "Круг " + (option.Slot + 1) + : option.Label, + () => + { + HideChoiceOverlay(); + OnIntentClicked( + new ChooseIntent { Index = index }, + ignoreInputLock: true); + }, + 16); + UiKit.SetPreferredHeight(button, 46f); + } + + _choiceOverlay.SetAsLastSibling(); + _choiceOverlay.gameObject.SetActive(true); + _inputLock.Acquire("choice"); + } + + private void HideChoiceOverlay() + { + if (_choiceOverlay == null) + return; + + _choicePromptKey = null; + _choiceOverlay.gameObject.SetActive(false); + _inputLock.Release("choice"); + } + private void BuildReconnectOverlay() { _reconnectOverlay = UiKit.CreatePanel( @@ -1426,6 +1546,15 @@ namespace Vanguard.UI if (_view != null) { + if (IsRearGuardChoice()) + { + foreach (var option in _view.Choice.Options) + { + if (option.Slot is >= 0 and < 5) + state.Highlights.Add(new BoardSlotKey(_view.You, option.Slot)); + } + } + foreach (var intent in _view.LegalIntents ?? new List()) { switch (intent) @@ -1718,7 +1847,24 @@ namespace Vanguard.UI private void OnBoard3DSlotClicked(BoardCardRef slot) { - if (_view == null || _sending || _inputLock.IsLocked || _discardChoiceActive) + if (_view == null || _sending || _discardChoiceActive) + return; + + if (IsRearGuardChoice() && slot.Player == _view.You) + { + var index = _view.Choice.Options.FindIndex( + option => option.Slot == slot.Slot); + if (index >= 0) + { + HideChoiceOverlay(); + OnIntentClicked( + new ChooseIntent { Index = index }, + ignoreInputLock: true); + return; + } + } + + if (_inputLock.IsLocked) return; if (slot.Zone == BoardZone.Guardian) @@ -2877,7 +3023,8 @@ namespace Vanguard.UI .Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent) && intent is not ActivateAbilityIntent - && intent is not ResolveAbilityIntent) + && intent is not ResolveAbilityIntent + && !(intent is ChooseIntent && IsRearGuardChoice())) .ToList(); if (_pendingIntents.Count > 0) diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 329406b..dcdfac9 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -325,6 +325,61 @@ namespace Vanguard.Tests } } + [Test] + public void Rear_Guard_Choice_Shows_Overlay_With_Circles() + { + var config = new AppConfig(); + config.Load(); + var tokens = new TokenStore(); + var api = new ApiClient(config, tokens); + var player = new PlayerState(); + var auth = new AuthService(api, tokens, player); + var meta = new MetaService(api, player); + var canvas = UiKit.CreateCanvas("Choice Test Canvas"); + + try + { + var battle = new BattleView(canvas.transform, meta, () => { }); + var match = new MatchView + { + You = 0, + ActivePlayer = 0, + Choice = new ChoiceView + { + Player = 0, + Prompt = "Выберите круг для призыва Barcgal", + Options = new List + { + new() { Slot = 0, Label = "Круг 1" }, + new() { Slot = 1, Label = "Круг 2" }, + new() { Slot = 2, Label = "Круг 3" }, + new() { Slot = 3, Label = "Круг 4" }, + new() { Slot = 4, Label = "Круг 5" }, + }, + }, + LegalIntents = new List(), + }; + + typeof(BattleView) + .GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(battle, match); + typeof(BattleView) + .GetMethod("SyncChoicePrompt", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(battle, null); + + var overlay = Resources.FindObjectsOfTypeAll() + .First(go => go.name == "ChoiceOverlay"); + Assert.IsTrue(overlay.activeSelf); + Assert.IsTrue(Resources.FindObjectsOfTypeAll() + .Any(go => go.name == "Choice_3")); + } + finally + { + Object.DestroyImmediate(canvas.gameObject); + Cleanup(); + } + } + private static RectTransform FindRect(string name) => Resources.FindObjectsOfTypeAll() .Where(go => go.scene.IsValid() && go.name == name) diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs index ac16cdf..8b97667 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScripts.cs @@ -351,7 +351,10 @@ public sealed class BarcgalScript : CardScript Condition = ctx => ctx.Other is not null && ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.RoyalPaladin, - Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner), + Resolve = ctx => ctx.Engine.AskEmptyRearGuardChoice( + ctx.Owner, + "Выберите круг для призыва Barcgal", + slot => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner, slot)), }; } } diff --git a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs index f7eaf03..d606126 100644 --- a/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs +++ b/server/src/Vanguard.Rules/Engine/Abilities/CardScriptsBT01.cs @@ -744,7 +744,10 @@ public sealed class ReturnToDeckOnBoostScript : CardScript Condition = ctx => ctx.Other is not null && ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == _clan, - Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner), + Resolve = ctx => ctx.Engine.AskEmptyRearGuardChoice( + ctx.Owner, + "Выберите круг для призыва карты", + slot => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner, slot)), }; yield return new AbilityDefinition @@ -802,7 +805,10 @@ public sealed class GuidingZombieScript : CardScript Condition = ctx => ctx.Other is not null && ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.Granblue, - Resolve = ctx => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner), + Resolve = ctx => ctx.Engine.AskEmptyRearGuardChoice( + ctx.Owner, + "Выберите круг для призыва карты", + slot => ctx.Engine.CallFromSoul(ctx.Source, ctx.Owner, slot)), }; } } diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 5789ecf..486ff69 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -585,14 +585,13 @@ public sealed class GameEngine u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan); /// Призыв карты из души — используется способностями вроде Barcgal. - public bool CallFromSoul(CardInstance card, int player) + public bool CallFromSoul(CardInstance card, int player, int slot = -1) { var p = State.Players[player]; if (!p.Soul.Contains(card)) return false; - var slot = Array.FindIndex(p.RearGuards, u => u is null); - if (slot < 0) + if (slot < 0 || slot >= PlayerState.RearGuardSlots || p.RearGuards[slot] is not null) return false; p.Soul.Remove(card); diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 5f3784c..b277be6 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -795,7 +795,17 @@ public class GameEngineTests Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); Assert.Null(engine.State.PendingAbility); - Assert.Contains(engine.State.Players[0].RearGuards, u => u?.PrintingId == Barcgal); + Assert.NotNull(engine.State.PendingChoice); + Assert.Equal(Step.Choice, engine.State.Step); + Assert.Equal(5, engine.State.PendingChoice!.Options.Count); + + // Choose a non-first circle to verify the player controls placement. + Assert.True(engine.Execute(new ChooseCommand(3)).Accepted); + + Assert.Null(engine.State.PendingChoice); + Assert.Equal( + Barcgal, + engine.State.Players[0].RearGuards[3]?.PrintingId); Assert.DoesNotContain(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal); }