diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs index baf5bfd..d2b422c 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs @@ -221,8 +221,10 @@ namespace Vanguard.Board3D public sealed class BoardRenderState { public HashSet Highlights { get; } = new HashSet(); + public HashSet SelectedHighlights { get; } = new HashSet(); public HashSet DropTargets { get; } = new HashSet(); public bool GuardianDropAllowed { get; set; } + public HashSet AvailableHandInstanceIds { get; } = new HashSet(); public HashSet SelectedHandInstanceIds { get; } = new HashSet(); public bool MulliganSelecting { get; set; } public bool DamageChoice { get; set; } diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs index 2e2cdd0..f8525ef 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs @@ -78,12 +78,12 @@ namespace Vanguard.Board3D return view; } - public void SetHighlighted(bool highlighted) + public void SetHighlighted(bool highlighted, Color? color = null) { - ApplyColor(highlighted); + ApplyColor(highlighted, color ?? _highlightColor); } - private void ApplyColor(bool highlighted) + private void ApplyColor(bool highlighted, Color? color = null) { if (_renderer == null) return; @@ -93,7 +93,7 @@ namespace Vanguard.Board3D BoardMaterials.SetColor( _block, _renderer.sharedMaterial, - highlighted ? _highlightColor : _baseColor); + highlighted ? (color ?? _highlightColor) : _baseColor); _renderer.SetPropertyBlock(_block); } } diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index 98abdb3..4e34fc3 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -29,6 +29,7 @@ namespace Vanguard.Board3D private readonly BoardZoneView[] _damageZones = new BoardZoneView[2]; private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2]; private readonly HashSet _activeHighlights = new HashSet(); + private readonly HashSet _selectedHighlights = new HashSet(); private readonly HashSet _validDropSlots = new HashSet(); private readonly List _handBindings = new List(); private bool _guardianDropAllowed; @@ -690,9 +691,11 @@ namespace Vanguard.Board3D Stats = "P" + card.Power + " C" + card.Critical, StatsModified = card.Power != card.BasePower || card.Critical != card.BaseCritical, - Highlight = state.Highlights.Contains(key) - ? _config.highlightColor - : (Color?)null, + Highlight = state.SelectedHighlights.Contains(key) + ? _config.selectedColor + : state.Highlights.Contains(key) + ? _config.highlightColor + : (Color?)null, }); } @@ -706,13 +709,21 @@ namespace Vanguard.Board3D var battleCards = view.Battle != null && view.Battle.AttackerPlayer != player.Index ? view.Battle.GuardCards ?? new List() : new List(); - var total = guardians.Count + battleCards.Count; + + // Guardian and battle guard lists contain the same cards after a + // guard command; render each physical instance only once. + var cards = guardians + .Concat(battleCards) + .GroupBy(card => card.InstanceId) + .Select(group => group.First()) + .ToList(); + var total = cards.Count; if (total == 0) return; - for (var i = 0; i < guardians.Count; i++) + for (var i = 0; i < total; i++) { - var card = guardians[i]; + var card = cards[i]; specs.Add(new CardSpec { Key = "G:" + player.Index + ":" + card.InstanceId, @@ -727,27 +738,6 @@ namespace Vanguard.Board3D Rested = false, }); } - - for (var i = 0; i < battleCards.Count; i++) - { - var card = battleCards[i]; - specs.Add(new CardSpec - { - Key = "BG:" + player.Index + ":" + card.InstanceId, - Ref = BoardCardRef.Guardian(player.Index, guardians.Count + i, card), - Art = ResolveArt(card.PrintingId), - Position = _layout.GuardianPosition( - localSeat, - guardians.Count + i, - total), - BaseRotation = Quaternion.Euler( - 0f, - _layout.BaseYaw(localSeat) + 90f, - 0f), - FaceUp = true, - Rested = false, - }); - } } private void AddDamageSpecs( @@ -843,7 +833,8 @@ namespace Vanguard.Board3D Color? highlight = null; if (state.SelectedHandInstanceIds.Contains(card.InstanceId)) highlight = _config.selectedColor; - else if (state.MulliganSelecting) + else if (state.AvailableHandInstanceIds.Contains(card.InstanceId) + || state.MulliganSelecting) highlight = _config.highlightColor; specs.Add(new CardSpec @@ -1025,6 +1016,10 @@ namespace Vanguard.Board3D foreach (var key in state.Highlights) _activeHighlights.Add(key); + _selectedHighlights.Clear(); + foreach (var key in state.SelectedHighlights) + _selectedHighlights.Add(key); + _validDropSlots.Clear(); foreach (var key in state.DropTargets) _validDropSlots.Add(key); @@ -1037,9 +1032,13 @@ namespace Vanguard.Board3D { foreach (var slot in _slots) { - var highlighted = _activeHighlights.Contains(slot.Key) + var selected = _selectedHighlights.Contains(slot.Key); + var highlighted = selected + || _activeHighlights.Contains(slot.Key) || (_dragging && _validDropSlots.Contains(slot.Key)); - slot.SetHighlighted(highlighted); + slot.SetHighlighted( + highlighted, + selected ? _config.selectedColor : _config.highlightColor); } if (_guardianZone != null) diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 344c7e6..c0c6599 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -95,6 +95,13 @@ namespace Vanguard.UI private Action _discardCancelAction; private int _pendingBoosterSlot = -1; private int _pendingBoostAttackerSlot = -2; + private readonly Dictionary _guardSelected = + new Dictionary(); + private int _guardSelectedInterceptSlot = -1; + private int _guardDiscardInstanceId; + private string _guardDiscardPrintingId; + private ActivateAbilityIntent _pendingAbilityCost; + private int _abilityCostDiscardInstanceId; private int? _triggerPowerSlot; private readonly HashSet _mulliganSelected = new HashSet(); private bool _mulliganUsed; @@ -285,6 +292,8 @@ namespace Vanguard.UI _abilityDamageIndex = -1; _pendingBoosterSlot = -1; _pendingBoostAttackerSlot = -2; + ClearGuardSelection(); + ClearAbilityCostSelection(); _triggerPowerSlot = null; _showAllIntents = false; _autoFinishMulligan = false; @@ -747,6 +756,8 @@ namespace Vanguard.UI _autoFinishMulliganAt = 0f; } FinishDiscardSelection(); + PruneGuardSelection(); + PruneAbilityCostSelection(); _triggerPowerSlot = null; PrepareTriggerSelection(); if (_view.Clock != null) @@ -1088,6 +1099,16 @@ namespace Vanguard.UI if (_discardChoiceActive) return; + if (_pendingAbilityCost != null + && intent is not ActivateAbilityIntent + && intent is not ConcedeIntent) + { + SetStatus( + "Сначала подтвердите оплату способности.", + new Color(1f, 0.70f, 0.35f)); + return; + } + if (_abilityDamageIndex >= 0) { if (intent is ActivateAbilityIntent activate) @@ -1660,9 +1681,13 @@ namespace Vanguard.UI var state = new BoardRenderState { MulliganSelecting = IsMulliganSelecting(), - DamageChoice = IsDamageChoice(), + // Damage cards are highlighted only while an ability cost is + // being paid, not for ordinary damage choices. + DamageChoice = false, AbilityDamageTargeting = IsAbilityDamageTargeting(), - AbilityDamageIndex = _abilityDamageIndex, + AbilityDamageIndex = IsAbilityDamageTargeting() + ? _abilityDamageIndex + : -1, MatchFinished = _view != null && _view.IsFinished, }; @@ -1708,6 +1733,64 @@ namespace Vanguard.UI } } + if (_view != null) + { + var hand = _view.Players + .FirstOrDefault(player => player.Index == _view.You) + ?.Hand ?? new List(); + var legal = _view.LegalIntents ?? new List(); + + if (IsGuardStep()) + { + foreach (var guard in legal.OfType()) + { + foreach (var card in hand) + { + if (card.PrintingId == guard.PrintingId) + state.AvailableHandInstanceIds.Add(card.InstanceId); + } + } + + foreach (var instanceId in _guardSelected.Keys) + if (state.AvailableHandInstanceIds.Contains(instanceId)) + state.SelectedHandInstanceIds.Add(instanceId); + + foreach (var intercept in legal.OfType()) + { + state.Highlights.Add(new BoardSlotKey(_view.You, intercept.Slot)); + } + + if (_guardSelectedInterceptSlot >= 0) + { + state.SelectedHighlights.Add( + new BoardSlotKey(_view.You, _guardSelectedInterceptSlot)); + } + } + + if (_discardChoiceActive) + { + var discardPrintings = new HashSet( + _discardAbilityChoices + .Select(item => item.DiscardPrintingId) + .Concat(_discardGuardChoices + .Select(item => item.DiscardPrintingId)) + .Where(item => !string.IsNullOrEmpty(item))); + foreach (var card in hand) + { + if (discardPrintings.Contains(card.PrintingId)) + state.AvailableHandInstanceIds.Add(card.InstanceId); + } + } + + if (_pendingAbilityCost != null + && _abilityCostDiscardInstanceId != 0 + && hand.Any(card => + card.InstanceId == _abilityCostDiscardInstanceId)) + { + state.SelectedHandInstanceIds.Add(_abilityCostDiscardInstanceId); + } + } + if (IsHandChoice()) { var hand = _view.Players @@ -1769,15 +1852,17 @@ namespace Vanguard.UI private bool IsAbilityDamageTargeting() { - if (_view == null || _view.Choice != null || _view.ActivePlayer != _view.You) + if (_view == null + || _view.Choice != null + || _view.ActivePlayer != _view.You + || _pendingAbilityCost == null + || !RequiresCounterBlast(_pendingAbilityCost)) + { return false; + } var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); - if (self == null || self.FaceUpDamage.Count == 0) - return false; - - return (_view.LegalIntents ?? new List()) - .Any(intent => intent is ActivateAbilityIntent or ResolveAbilityIntent); + return self != null && self.FaceUpDamage.Count > 0; } private void OnBoard3DCardClicked(BoardCardRef card) @@ -1800,6 +1885,20 @@ namespace Vanguard.UI if (_inputLock.IsLocked) return; + if (_pendingAbilityCost != null && card.Zone != BoardZone.Damage) + { + SetStatus( + "Сначала подтвердите оплату способности.", + new Color(1f, 0.70f, 0.35f)); + return; + } + + if (IsGuardStep()) + { + ToggleGuardSelection(card); + return; + } + var targetSelection = _pendingIntents.Count > 0 || IsMulliganSelecting() || IsDamageChoice() @@ -1858,6 +1957,368 @@ namespace Vanguard.UI } } + private bool IsGuardStep() => + _view != null + && _view.Step == GameStep.Guard + && _view.ActivePlayer == _view.You; + + private void ToggleGuardSelection(BoardCardRef card) + { + if (_view == null || card.Player != _view.You) + return; + + if (card.Zone == BoardZone.Hand) + { + ToggleGuardHand(card); + return; + } + + if (card.Zone is BoardZone.Vanguard or BoardZone.RearGuard) + { + var legal = _view.LegalIntents ?? new List(); + var intercept = legal.OfType() + .FirstOrDefault(intent => intent.Slot == card.Slot); + if (intercept == null) + { + SetStatus( + "Эта карта сейчас не может перехватить атаку.", + new Color(1f, 0.55f, 0.45f)); + return; + } + + _guardSelectedInterceptSlot = + _guardSelectedInterceptSlot == card.Slot ? -1 : card.Slot; + RefreshGuardSelection(); + } + } + + private void ToggleGuardHand(BoardCardRef card) + { + if (_guardSelected.Remove(card.InstanceId)) + { + RefreshGuardSelection(); + return; + } + + var legal = _view.LegalIntents ?? new List(); + var variants = legal.OfType() + .Where(intent => intent.PrintingId == card.PrintingId) + .ToList(); + if (variants.Count == 0) + { + SetStatus( + "Эту карту нельзя использовать для защиты.", + new Color(1f, 0.55f, 0.45f)); + return; + } + + var distinct = variants + .GroupBy(intent => intent.DiscardPrintingId ?? string.Empty) + .Select(group => group.First()) + .ToList(); + var discardVariants = distinct + .Where(intent => !string.IsNullOrEmpty(intent.DiscardPrintingId)) + .ToList(); + if (discardVariants.Count == 0) + { + AddGuardSelection(card.InstanceId, distinct[0]); + return; + } + + if (discardVariants.Count == 1) + { + AddGuardSelection(card.InstanceId, discardVariants[0]); + return; + } + + _guardDiscardInstanceId = card.InstanceId; + _guardDiscardPrintingId = card.PrintingId; + StartDiscardSelection( + "Perfect Guard: выберите карту в руке для сброса.", + null, + discardVariants, + () => + { + _guardDiscardInstanceId = 0; + _guardDiscardPrintingId = null; + RefreshGuardSelection(); + }); + Render(); + } + + private void AddGuardSelection(int instanceId, GuardIntent intent) + { + _guardSelected[instanceId] = intent; + RefreshGuardSelection(); + } + + private void RefreshGuardSelection() + { + if (_view == null) + return; + + var count = _guardSelected.Count + + (_guardSelectedInterceptSlot >= 0 ? 1 : 0); + SetStatus( + count == 0 + ? "Защита: выберите карты в руке или на поле." + : "Защита: выбрано " + count + " карт.", + new Color(0.72f, 0.86f, 1f)); + Render(); + } + + private void ClearGuardSelection() + { + _guardSelected.Clear(); + _guardSelectedInterceptSlot = -1; + _guardDiscardInstanceId = 0; + _guardDiscardPrintingId = null; + } + + private void PruneGuardSelection() + { + if (!IsGuardStep()) + { + ClearGuardSelection(); + return; + } + + var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); + var hand = self?.Hand ?? new List(); + var handIds = new HashSet(hand.Select(card => card.InstanceId)); + foreach (var instanceId in _guardSelected.Keys.ToList()) + { + if (!handIds.Contains(instanceId)) + _guardSelected.Remove(instanceId); + } + + if (_guardSelectedInterceptSlot >= 0 + && !(_view.LegalIntents ?? new List()) + .OfType() + .Any(intent => intent.Slot == _guardSelectedInterceptSlot)) + { + _guardSelectedInterceptSlot = -1; + } + } + + private int GuardSelectionShield() + { + var shield = 0; + foreach (var guard in _guardSelected.Values) + { + var meta = ResolveCatalogItem(guard.PrintingId); + if (meta != null && !meta.IsSentinel) + shield += Math.Max(0, meta.Shield); + } + + return shield; + } + + private async void ConfirmGuardSelection() + { + if (_view == null + || _connection == null + || _cts == null + || _sending) + { + return; + } + + var intents = new List(); + if (_guardSelectedInterceptSlot >= 0) + { + var intercept = (_view.LegalIntents ?? new List()) + .OfType() + .FirstOrDefault(intent => intent.Slot == _guardSelectedInterceptSlot); + if (intercept != null) + intents.Add(intercept); + } + + intents.AddRange(_guardSelected.Values); + + if (intents.Count == 0) + { + OnIntentClicked(new PassGuardIntent()); + return; + } + + ClearGuardSelection(); + Render(); + _sending = true; + SetStatus("Отправляем защиту…", Muted); + try + { + var acceptedAll = true; + foreach (var intent in intents) + { + var result = await _connection.SubmitAsync(intent, _cts.Token); + if (!result.Accepted) + { + acceptedAll = false; + SetStatus( + "Защита отклонена (" + result.Code + "): " + + (result.Reason ?? "без причины"), + new Color(1f, 0.55f, 0.45f)); + break; + } + } + + if (acceptedAll) + { + // "Защищаться" is the final confirmation: add the selected + // guards and pass the guard step in one action. + await _connection.SubmitAsync(new PassGuardIntent(), _cts.Token); + } + + await RequestSnapshotAsync(); + } + catch (OperationCanceledException) + { + // экран закрыли + } + catch (Exception exception) + { + Debug.LogException(exception); + SetStatus( + "Ошибка отправки защиты: " + exception.Message, + new Color(1f, 0.55f, 0.45f)); + } + finally + { + _sending = false; + if (_view != null) + Render(); + } + } + + private bool RequiresCounterBlast(ActivateAbilityIntent intent) + { + if (intent == null || _view == null) + return false; + + var card = InstanceAt(_view.You, intent.InstanceId); + var meta = ResolveCatalogItem(card?.PrintingId ?? string.Empty); + if (meta == null) + return false; + + var text = ((meta.TextEn ?? string.Empty) + " " + + (meta.TextRu ?? string.Empty)).ToLowerInvariant(); + return text.Contains("counter-blast") + || text.Contains("counter blast") + || text.Contains("встречн"); + } + + private void ActivateAbility(ActivateAbilityIntent intent) + { + if (intent == null || _view == null || _pendingAbilityCost != null) + return; + + if (!string.IsNullOrEmpty(intent.DiscardPrintingId)) + { + var legal = _view.LegalIntents ?? new List(); + var variants = legal.OfType() + .Where(variant => variant.InstanceId == intent.InstanceId + && !string.IsNullOrEmpty(variant.DiscardPrintingId)) + .ToList(); + _pendingAbilityCost = null; + _abilityCostDiscardInstanceId = 0; + _abilityDamageIndex = -1; + StartDiscardSelection( + "Выберите карту в руке для сброса.", + variants, + null); + Render(); + return; + } + + if (RequiresCounterBlast(intent)) + { + BeginAbilityCost(intent); + return; + } + + OnIntentClicked(intent); + } + + private void BeginAbilityCost(ActivateAbilityIntent intent) + { + _pendingAbilityCost = intent; + _abilityCostDiscardInstanceId = 0; + _abilityDamageIndex = -1; + _pendingIntents.Clear(); + _highlightSlots.Clear(); + SetStatus( + "Оплата способности: выберите карты для цены.", + new Color(0.98f, 0.82f, 0.42f)); + Render(); + } + + private void ConfirmAbilityCost() + { + if (_pendingAbilityCost == null || _view == null || _sending) + return; + + if (RequiresCounterBlast(_pendingAbilityCost) + && _abilityDamageIndex < 0) + { + SetStatus( + "Выберите карту урона для Counter Blast.", + new Color(1f, 0.70f, 0.35f)); + return; + } + + var activate = new ActivateAbilityIntent + { + InstanceId = _pendingAbilityCost.InstanceId, + DiscardPrintingId = _pendingAbilityCost.DiscardPrintingId, + DamageIndex = _abilityDamageIndex, + }; + ClearAbilityCostSelection(); + OnIntentClicked(activate); + } + + private void ClearAbilityCostSelection() + { + _pendingAbilityCost = null; + _abilityCostDiscardInstanceId = 0; + _abilityDamageIndex = -1; + } + + private void PruneAbilityCostSelection() + { + if (_pendingAbilityCost == null) + return; + + if (_view == null || _view.ActivePlayer != _view.You) + { + ClearAbilityCostSelection(); + return; + } + + var stillLegal = (_view.LegalIntents ?? new List()) + .OfType() + .Any(intent => intent.InstanceId == _pendingAbilityCost.InstanceId); + if (!stillLegal) + ClearAbilityCostSelection(); + } + + private bool IsAbilityCostReady() + { + if (_pendingAbilityCost == null) + return false; + + return !RequiresCounterBlast(_pendingAbilityCost) + || _abilityDamageIndex >= 0; + } + + private void ToggleGuardFromDrop(BoardCardRef source) + { + if (!IsGuardStep()) + return; + + ToggleGuardSelection(source); + } + private void OpenCardMenu(BoardCardRef card) { if (_view == null || _board3D == null) return; @@ -2140,7 +2601,7 @@ namespace Vanguard.UI .ToList(); if (discardVariants.Count == 0) { - OnIntentClicked(free ?? distinct[0]); + ActivateAbility(free ?? distinct[0]); return; } @@ -2152,7 +2613,7 @@ namespace Vanguard.UI { new CardActionMenuItem( "Без сброса", - () => OnIntentClicked(free)), + () => ActivateAbility(free)), new CardActionMenuItem( "Сбросить карту в руке", () => StartDiscardSelection( @@ -2209,8 +2670,12 @@ namespace Vanguard.UI item => item.DiscardPrintingId == printing); if (ability != null) { + var discardInstanceId = card.InstanceId; FinishDiscardSelection(); - OnIntentClicked(ability); + _pendingAbilityCost = ability; + _abilityCostDiscardInstanceId = discardInstanceId; + _abilityDamageIndex = -1; + Render(); return; } @@ -2218,7 +2683,16 @@ namespace Vanguard.UI item => item.DiscardPrintingId == printing); if (guard != null) { + var guardInstanceId = _guardDiscardInstanceId; FinishDiscardSelection(); + if (guardInstanceId != 0) + { + _guardDiscardInstanceId = 0; + _guardDiscardPrintingId = null; + AddGuardSelection(guardInstanceId, guard); + return; + } + OnIntentClicked(guard); return; } @@ -2253,6 +2727,8 @@ namespace Vanguard.UI _discardGuardChoices.Clear(); _discardCancelAction = null; _discardPrompt = null; + _guardDiscardInstanceId = 0; + _guardDiscardPrintingId = null; } private void CancelDiscardSelection() @@ -2354,6 +2830,14 @@ namespace Vanguard.UI if (_view == null || _sending || _inputLock.IsLocked) return; + if (IsGuardStep() + && target.Zone == BoardZone.Guardian + && (source.Zone == BoardZone.Hand || source.IsField)) + { + ToggleGuardFromDrop(source); + return; + } + var legal = _view.LegalIntents ?? new List(); IntentPayload intent = null; @@ -2486,6 +2970,30 @@ namespace Vanguard.UI if (_view == null || _inputLock.IsLocked) return; + if (_discardChoiceActive) + { + CancelDiscardSelection(); + return; + } + + if (_pendingAbilityCost != null) + { + ClearAbilityCostSelection(); + SetStatus("Оплата способности отменена.", Muted); + Render(); + return; + } + + if (IsGuardStep() + && (_guardSelected.Count > 0 + || _guardSelectedInterceptSlot >= 0)) + { + ClearGuardSelection(); + SetStatus("Выбор защиты снят.", Muted); + Render(); + return; + } + if (card.Zone == BoardZone.Vanguard) OpenZone(BoardZone.Soul, card.Player); } @@ -2566,6 +3074,15 @@ namespace Vanguard.UI ? "Выберите карту в руке для сброса." : _discardPrompt; } + else if (_pendingAbilityCost != null) + { + title = "ОПЛАТА"; + hint = RequiresCounterBlast(_pendingAbilityCost) + ? _abilityDamageIndex < 0 + ? "Выберите карту урона для Counter Blast." + : "Оплата выбрана. Подтвердите в панели действий." + : "Оплата выбрана. Подтвердите в панели действий."; + } else if (_pendingBoosterSlot >= 0 && _pendingIntents.Count > 0) { title = "ПОДДЕРЖКА"; @@ -2618,7 +3135,12 @@ namespace Vanguard.UI else if (_view.Step == GameStep.Guard) { title = "ЗАЩИТА"; - hint = "Выберите защиту или не защищайтесь."; + var guardCount = _guardSelected.Count + + (_guardSelectedInterceptSlot >= 0 ? 1 : 0); + hint = guardCount == 0 + ? "Кликните карты в руке или на поле для защиты." + : "Выбрано карт: " + guardCount + + ". Щит: +" + GuardSelectionShield() + "."; } else if (_view.Phase == GamePhase.Battle) { @@ -2661,7 +3183,7 @@ namespace Vanguard.UI _phasePanel.anchorMax = new Vector2(1f, 0.5f); _phasePanel.pivot = new Vector2(1f, 0.5f); _phasePanel.anchoredPosition = new Vector2(-468f, 0f); - _phasePanel.sizeDelta = new Vector2(156f, 188f); + _phasePanel.sizeDelta = new Vector2(190f, 220f); UiKit.AddVerticalLayout(_phasePanel, 8, 6); _phasePanel.gameObject.SetActive(false); } @@ -2672,12 +3194,16 @@ namespace Vanguard.UI return; ClearChildren(_phasePanel); - if (view == null || _pendingIntents.Count > 0) + if (view == null + || _pendingIntents.Count > 0 + || _discardChoiceActive) { _phasePanel.gameObject.SetActive(false); return; } + var isGuardStep = IsGuardStep(); + var isAbilityCost = _pendingAbilityCost != null; var intents = (view.LegalIntents ?? new List()) .Where(IsPhaseIntent) .Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1)) @@ -2685,7 +3211,7 @@ namespace Vanguard.UI // inactive finish button after the exchange is misleading. .Where(intent => !(intent is FinishMulliganIntent && _mulliganUsed)) .ToList(); - if (intents.Count == 0) + if (intents.Count == 0 && !isGuardStep && !isAbilityCost) { _phasePanel.gameObject.SetActive(false); return; @@ -2695,12 +3221,20 @@ namespace Vanguard.UI var title = UiKit.CreateText( _phasePanel, "PhaseTitle", - "ФАЗЫ", + "ДЕЙСТВИЯ", 14, new Color(0.72f, 0.80f, 0.94f)); title.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(title, 22f); + if (isGuardStep) + AddGuardPhaseButton(); + if (isAbilityCost) + { + AddAbilityCostPhaseButton(); + return; + } + foreach (var ride in (view.LegalIntents ?? new List()) .OfType() .GroupBy(item => item.PrintingId) @@ -2744,6 +3278,45 @@ namespace Vanguard.UI } } + private void AddGuardPhaseButton() + { + var count = _guardSelected.Count + + (_guardSelectedInterceptSlot >= 0 ? 1 : 0); + var button = UiKit.CreateButton( + _phasePanel, + "Phase_Guard", + count == 0 ? "Не защищаться" : "Защищаться", + () => + { + if (_guardSelected.Count == 0 + && _guardSelectedInterceptSlot < 0) + { + OnIntentClicked(new PassGuardIntent()); + } + else + { + ConfirmGuardSelection(); + } + }, + 14); + button.interactable = !_inputLock.IsLocked && !_sending; + UiKit.SetPreferredHeight(button, 40f); + } + + private void AddAbilityCostPhaseButton() + { + var button = UiKit.CreateButton( + _phasePanel, + "Phase_ConfirmAbilityCost", + "Подтвердить оплату", + ConfirmAbilityCost, + 14); + button.interactable = !_inputLock.IsLocked + && !_sending + && IsAbilityCostReady(); + UiKit.SetPreferredHeight(button, 40f); + } + private static bool IsPhaseIntent(IntentPayload intent) => intent is FinishMulliganIntent or SkipRideIntent or EnterBattleIntent or EndTurnIntent; @@ -2838,7 +3411,7 @@ namespace Vanguard.UI } var attackPower = attacker.Power; - var shield = defender.Power + battle.GuardShield; + var shield = defender.Power + battle.GuardShield + GuardSelectionShield(); var text = battle.Nullified ? "POWER " + attackPower + " · NULLIFIED" : "POWER " + attackPower + " vs SHIELD " + shield; @@ -3073,7 +3646,10 @@ namespace Vanguard.UI if (sources.Count == 1) { ClearSelection(rebuild: false); - OnIntentClicked(sources[0]); + if (sources[0] is ActivateAbilityIntent ability) + ActivateAbility(ability); + else + OnIntentClicked(sources[0]); return; } @@ -3106,7 +3682,10 @@ namespace Vanguard.UI if (withTargets.Count == 0) { ClearSelection(rebuild: false); - OnIntentClicked(sources[0]); + if (sources[0] is ActivateAbilityIntent ability) + ActivateAbility(ability); + else + OnIntentClicked(sources[0]); return; } @@ -3354,7 +3933,6 @@ namespace Vanguard.UI var legal = view.LegalIntents ?? new List(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); var abilities = playable.OfType().ToList(); - var guards = playable.OfType().ToList(); var rides = playable.OfType().ToList(); var board = playable .Where(intent => IsBoardIntent(intent) @@ -3365,6 +3943,7 @@ namespace Vanguard.UI var global = playable .Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent) + && intent is not PassGuardIntent && intent is not ActivateAbilityIntent && intent is not ResolveAbilityIntent && !(intent is ChooseIntent @@ -3464,12 +4043,9 @@ namespace Vanguard.UI UiKit.SetPreferredHeight(toggle, 38f); } - if (abilities.Count > 0) + if (abilities.Count > 0 && _pendingAbilityCost == null) AddAbilityButton(abilities); - if (guards.Count > 0) - AddGuardButton(guards); - if (rides.Count > 0) AddRideButtons(rides); diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 078b35c..1b72a23 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -258,6 +258,71 @@ namespace Vanguard.Tests } } + [Test] + public void Guard_Control_Is_One_Button_In_Action_Panel() + { + 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("Guard Panel Test Canvas"); + + try + { + var battle = new BattleView(canvas.transform, meta, () => { }); + var match = new MatchView + { + You = 0, + ActivePlayer = 0, + Phase = GamePhase.Battle, + Step = GameStep.Guard, + LegalIntents = new List + { + new GuardIntent { PrintingId = "BT01/004EN" }, + new PassGuardIntent(), + new ConcedeIntent(), + }, + }; + + var viewField = typeof(BattleView) + .GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!; + var rebuild = typeof(BattleView) + .GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!; + viewField.SetValue(battle, match); + rebuild.Invoke(battle, new object[] { match }); + + Assert.IsTrue( + FindRect("PhasePanel").gameObject.activeSelf, + "Guard controls must be in the action panel"); + var guardButton = Resources.FindObjectsOfTypeAll