Let player choose rear-guard circle for call-from-soul abilities
CI / test (push) Successful in 1m51s Details

This commit is contained in:
MpakobecEx 2026-10-23 18:45:33 +05:00
parent 69f2121e16
commit 6f514e85b6
6 changed files with 229 additions and 9 deletions

View File

@ -71,6 +71,10 @@ namespace Vanguard.UI
private Text _abilityOverlayName; private Text _abilityOverlayName;
private RectTransform _abilityOverlayButtons; private RectTransform _abilityOverlayButtons;
private int _abilityPromptInstanceId; private int _abilityPromptInstanceId;
private RectTransform _choiceOverlay;
private Text _choiceOverlayTitle;
private RectTransform _choiceOverlayButtons;
private string _choicePromptKey;
private MatchConnectionInfo _info; private MatchConnectionInfo _info;
private MatchView _view; private MatchView _view;
private bool _connecting; private bool _connecting;
@ -231,6 +235,7 @@ namespace Vanguard.UI
BuildPhasePanel(); BuildPhasePanel();
BuildCombatStats(); BuildCombatStats();
BuildAbilityOverlay(); BuildAbilityOverlay();
BuildChoiceOverlay();
BuildReconnectOverlay(); BuildReconnectOverlay();
_cardMenu = new CardActionMenuView(); _cardMenu = new CardActionMenuView();
_cardMenu.Build(_root); _cardMenu.Build(_root);
@ -301,6 +306,7 @@ namespace Vanguard.UI
_board3D?.SetVisible(true); _board3D?.SetVisible(true);
_overlays?.CloseAll(); _overlays?.CloseAll();
HideAbilityOverlay(); HideAbilityOverlay();
HideChoiceOverlay();
_selfZones?.Update(null, self: true); _selfZones?.Update(null, self: true);
_opponentZones?.Update(null, self: false); _opponentZones?.Update(null, self: false);
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
@ -333,6 +339,7 @@ namespace Vanguard.UI
_skipFeedback.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false);
_overlays?.CloseAll(); _overlays?.CloseAll();
HideAbilityOverlay(); HideAbilityOverlay();
HideChoiceOverlay();
_cardMenu?.Hide(); _cardMenu?.Hide();
_combatStats?.gameObject.SetActive(false); _combatStats?.gameObject.SetActive(false);
_board3D?.SetVisible(false); _board3D?.SetVisible(false);
@ -352,6 +359,7 @@ namespace Vanguard.UI
UpdateFeedback(); UpdateFeedback();
SanitizeInputLocks(); SanitizeInputLocks();
SyncAbilityPrompt(); SyncAbilityPrompt();
SyncChoicePrompt();
UpdateAutoPhases(); UpdateAutoPhases();
} }
@ -374,6 +382,8 @@ namespace Vanguard.UI
_inputLock.Release("card-menu"); _inputLock.Release("card-menu");
if (_abilityOverlay == null || !_abilityOverlay.gameObject.activeSelf) if (_abilityOverlay == null || !_abilityOverlay.gameObject.activeSelf)
_inputLock.Release("ability"); _inputLock.Release("ability");
if (_choiceOverlay == null || !_choiceOverlay.gameObject.activeSelf)
_inputLock.Release("choice");
if (!_feedbackPlaying && _feedbackQueue.Count == 0) if (!_feedbackPlaying && _feedbackQueue.Count == 0)
{ {
_feedbackLockHeld = false; _feedbackLockHeld = false;
@ -736,6 +746,7 @@ namespace Vanguard.UI
Render(); Render();
SyncAbilityPrompt(); SyncAbilityPrompt();
SyncChoicePrompt();
} }
private void OnEvents(ServerEvents events) private void OnEvents(ServerEvents events)
@ -1287,6 +1298,115 @@ namespace Vanguard.UI
_inputLock.Release("ability"); _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() private void BuildReconnectOverlay()
{ {
_reconnectOverlay = UiKit.CreatePanel( _reconnectOverlay = UiKit.CreatePanel(
@ -1426,6 +1546,15 @@ namespace Vanguard.UI
if (_view != null) 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<IntentPayload>()) foreach (var intent in _view.LegalIntents ?? new List<IntentPayload>())
{ {
switch (intent) switch (intent)
@ -1718,7 +1847,24 @@ namespace Vanguard.UI
private void OnBoard3DSlotClicked(BoardCardRef slot) 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; return;
if (slot.Zone == BoardZone.Guardian) if (slot.Zone == BoardZone.Guardian)
@ -2877,7 +3023,8 @@ namespace Vanguard.UI
.Where(intent => !IsBoardIntent(intent) .Where(intent => !IsBoardIntent(intent)
&& !IsPhaseIntent(intent) && !IsPhaseIntent(intent)
&& intent is not ActivateAbilityIntent && intent is not ActivateAbilityIntent
&& intent is not ResolveAbilityIntent) && intent is not ResolveAbilityIntent
&& !(intent is ChooseIntent && IsRearGuardChoice()))
.ToList(); .ToList();
if (_pendingIntents.Count > 0) if (_pendingIntents.Count > 0)

View File

@ -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<ChoiceOptionView>
{
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<IntentPayload>(),
};
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<GameObject>()
.First(go => go.name == "ChoiceOverlay");
Assert.IsTrue(overlay.activeSelf);
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Choice_3"));
}
finally
{
Object.DestroyImmediate(canvas.gameObject);
Cleanup();
}
}
private static RectTransform FindRect(string name) => private static RectTransform FindRect(string name) =>
Resources.FindObjectsOfTypeAll<GameObject>() Resources.FindObjectsOfTypeAll<GameObject>()
.Where(go => go.scene.IsValid() && go.name == name) .Where(go => go.scene.IsValid() && go.name == name)

View File

@ -351,7 +351,10 @@ public sealed class BarcgalScript : CardScript
Condition = ctx => Condition = ctx =>
ctx.Other is not null && ctx.Other is not null &&
ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.RoyalPaladin, 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)),
}; };
} }
} }

View File

@ -744,7 +744,10 @@ public sealed class ReturnToDeckOnBoostScript : CardScript
Condition = ctx => Condition = ctx =>
ctx.Other is not null && ctx.Other is not null &&
ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == _clan, 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 yield return new AbilityDefinition
@ -802,7 +805,10 @@ public sealed class GuidingZombieScript : CardScript
Condition = ctx => Condition = ctx =>
ctx.Other is not null && ctx.Other is not null &&
ctx.Catalog.CardOf(ctx.Other.PrintingId).Clan == Clan.Granblue, 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)),
}; };
} }
} }

View File

@ -585,14 +585,13 @@ public sealed class GameEngine
u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan); u is not null && State.Catalog.CardOf(u.PrintingId).Clan == clan);
/// <summary>Призыв карты из души — используется способностями вроде Barcgal.</summary> /// <summary>Призыв карты из души — используется способностями вроде Barcgal.</summary>
public bool CallFromSoul(CardInstance card, int player) public bool CallFromSoul(CardInstance card, int player, int slot = -1)
{ {
var p = State.Players[player]; var p = State.Players[player];
if (!p.Soul.Contains(card)) if (!p.Soul.Contains(card))
return false; return false;
var slot = Array.FindIndex(p.RearGuards, u => u is null); if (slot < 0 || slot >= PlayerState.RearGuardSlots || p.RearGuards[slot] is not null)
if (slot < 0)
return false; return false;
p.Soul.Remove(card); p.Soul.Remove(card);

View File

@ -795,7 +795,17 @@ public class GameEngineTests
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted); Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
Assert.Null(engine.State.PendingAbility); 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); Assert.DoesNotContain(engine.State.Players[0].Soul, c => c.PrintingId == Barcgal);
} }