From 6d8882ba2f72d04fc9cc20322509c38be9577e9d Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 23 Oct 2026 17:16:51 +0500 Subject: [PATCH] Select discard cards from hand and expose ride in phase panel --- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 214 +++++++++++++----- .../Vanguard/Runtime/UI/Localization.cs | 4 + docs/HANDOFF.md | 10 +- docs/SESSION-STATE.md | 10 +- 4 files changed, 175 insertions(+), 63 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 3ea1cff..cdca8e7 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -78,6 +78,12 @@ namespace Vanguard.UI private long _clockServerMs; private long _clockReceivedMs; private readonly List _pendingIntents = new List(); + private readonly List _discardAbilityChoices = + new List(); + private readonly List _discardGuardChoices = new List(); + private bool _discardChoiceActive; + private string _discardPrompt; + private Action _discardCancelAction; private int _pendingBoosterSlot = -1; private int? _triggerPowerSlot; private readonly HashSet _mulliganSelected = new HashSet(); @@ -249,6 +255,11 @@ namespace Vanguard.UI _reconnect.gameObject.SetActive(false); _result.gameObject.SetActive(false); _pendingIntents.Clear(); + _discardChoiceActive = false; + _discardAbilityChoices.Clear(); + _discardGuardChoices.Clear(); + _discardCancelAction = null; + _discardPrompt = null; _highlightSlots.Clear(); _mulliganSelected.Clear(); _mulliganUsed = false; @@ -984,6 +995,9 @@ namespace Vanguard.UI if (_inputLock.IsLocked && intent is not ConcedeIntent) return; + if (_discardChoiceActive) + return; + if (_abilityDamageIndex >= 0) { if (intent is ActivateAbilityIntent activate) @@ -1276,6 +1290,12 @@ namespace Vanguard.UI if (_view == null || _sending || _inputLock.IsLocked) return; + if (_discardChoiceActive) + { + HandleDiscardCardClicked(card); + return; + } + var targetSelection = _pendingIntents.Count > 0 || IsMulliganSelecting() || IsDamageChoice() @@ -1472,7 +1492,7 @@ namespace Vanguard.UI private void OnBoard3DSlotClicked(BoardCardRef slot) { - if (_view == null || _sending || _inputLock.IsLocked) + if (_view == null || _sending || _inputLock.IsLocked || _discardChoiceActive) return; if (slot.Zone == BoardZone.Guardian) @@ -1604,34 +1624,29 @@ namespace Vanguard.UI return; } - var items = new List(); if (free != null) - items.Add(new CardActionMenuItem("Без сброса", () => OnIntentClicked(free))); - - var hand = PlayerHand(); - foreach (var card in hand - .GroupBy(item => item.PrintingId) - .Select(group => group.First())) { - var variant = discardVariants.FirstOrDefault( - item => item.DiscardPrintingId == card.PrintingId); - if (variant == null) - continue; - - items.Add(new CardActionMenuItem( - "Сбросить: " + CardName(card.PrintingId), - () => OnIntentClicked(variant))); + ShowChoiceMenu( + "Способность", + new List + { + new CardActionMenuItem( + "Без сброса", + () => OnIntentClicked(free)), + new CardActionMenuItem( + "Сбросить карту в руке", + () => StartDiscardSelection( + "Выберите карту в руке для сброса.", + discardVariants, + null)), + }); + return; } - if (items.Count == 0) - { - items.AddRange(discardVariants.Select(variant => - new CardActionMenuItem( - "Сбросить: " + CardName(variant.DiscardPrintingId), - () => OnIntentClicked(variant)))); - } - - ShowChoiceMenu("Сброс для способности", items); + StartDiscardSelection( + "Выберите карту в руке для сброса.", + discardVariants, + null); } private void ShowGuardDiscardChoice( @@ -1654,37 +1669,108 @@ namespace Vanguard.UI return; } - var items = new List(); - var hand = PlayerHand(); - foreach (var card in hand - .GroupBy(item => item.PrintingId) - .Select(group => group.First())) - { - var variant = discardVariants.FirstOrDefault( - guard => guard.DiscardPrintingId == card.PrintingId); - if (variant == null) - continue; - - items.Add(new CardActionMenuItem( - "Perfect Guard: сбросить " + CardName(card.PrintingId), - () => OnIntentClicked(variant))); - } - - if (items.Count == 0) - { - items.AddRange(discardVariants.Select(guard => - new CardActionMenuItem( - "Perfect Guard: сбросить " + CardName(guard.DiscardPrintingId), - () => OnIntentClicked(guard)))); - } - - ShowChoiceMenu("Guard", items, onClosed); + StartDiscardSelection( + "Perfect Guard: выберите карту в руке для сброса.", + null, + discardVariants, + onClosed); } - private List PlayerHand() + private void HandleDiscardCardClicked(BoardCardRef card) { - var player = _view?.Players.FirstOrDefault(item => item.Index == _view.You); - return player?.Hand ?? new List(); + if (_view == null || card.Zone != BoardZone.Hand || card.Player != _view.You) + { + SetStatus("Выберите карту в руке для сброса.", new Color(0.98f, 0.82f, 0.42f)); + return; + } + + var printing = card.PrintingId; + var ability = _discardAbilityChoices.FirstOrDefault( + item => item.DiscardPrintingId == printing); + if (ability != null) + { + FinishDiscardSelection(); + OnIntentClicked(ability); + return; + } + + var guard = _discardGuardChoices.FirstOrDefault( + item => item.DiscardPrintingId == printing); + if (guard != null) + { + FinishDiscardSelection(); + OnIntentClicked(guard); + return; + } + + SetStatus("Эта карта не подходит для сброса.", new Color(1f, 0.55f, 0.45f)); + } + + private void StartDiscardSelection( + string prompt, + IReadOnlyList abilities, + IReadOnlyList guards, + Action onCancel = null) + { + _discardChoiceActive = true; + _discardPrompt = prompt; + _discardCancelAction = onCancel; + _discardAbilityChoices.Clear(); + if (abilities != null) + _discardAbilityChoices.AddRange(abilities); + _discardGuardChoices.Clear(); + if (guards != null) + _discardGuardChoices.AddRange(guards); + + SetStatus(prompt, new Color(0.98f, 0.82f, 0.42f)); + RebuildIntents(_view); + } + + private void FinishDiscardSelection() + { + _discardChoiceActive = false; + _discardAbilityChoices.Clear(); + _discardGuardChoices.Clear(); + _discardCancelAction = null; + _discardPrompt = null; + } + + private void CancelDiscardSelection() + { + if (!_discardChoiceActive) + return; + + var cancel = _discardCancelAction; + FinishDiscardSelection(); + if (_view != null) + { + RebuildIntents(_view); + SetStatus("Выбор сброса отменён.", Muted); + } + cancel?.Invoke(); + } + + private void BuildDiscardPrompt() + { + var prompt = UiKit.CreateText( + _intents, + "DiscardPrompt", + string.IsNullOrEmpty(_discardPrompt) + ? "Выберите карту в руке для сброса." + : _discardPrompt, + 16, + new Color(0.98f, 0.82f, 0.42f), + TextAnchor.UpperLeft); + prompt.horizontalOverflow = HorizontalWrapMode.Wrap; + UiKit.SetPreferredHeight(prompt, 72f); + + var cancel = UiKit.CreateButton( + _intents, + "DiscardCancel", + "Отмена", + CancelDiscardSelection, + 16); + UiKit.SetPreferredHeight(cancel, 44f); } private void ShowChoiceMenu( @@ -1898,6 +1984,22 @@ namespace Vanguard.UI title.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(title, 22f); + foreach (var ride in (view.LegalIntents ?? new List()) + .OfType() + .GroupBy(item => item.PrintingId) + .Select(group => group.First())) + { + var captured = ride; + var rideButton = UiKit.CreateButton( + _phasePanel, + "Phase_Ride_" + captured.PrintingId, + "Сменить: " + CardName(captured.PrintingId), + () => OnIntentClicked(captured), + 14); + rideButton.interactable = !_inputLock.IsLocked; + UiKit.SetPreferredHeight(rideButton, 40f); + } + foreach (var intent in intents) { var captured = intent; @@ -2523,6 +2625,12 @@ namespace Vanguard.UI { ClearChildren(_intents); + if (_discardChoiceActive) + { + BuildDiscardPrompt(); + return; + } + var legal = view.LegalIntents ?? new List(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); var abilities = playable.OfType().ToList(); diff --git a/client/Assets/Vanguard/Runtime/UI/Localization.cs b/client/Assets/Vanguard/Runtime/UI/Localization.cs index ebbe870..a44ae9a 100644 --- a/client/Assets/Vanguard/Runtime/UI/Localization.cs +++ b/client/Assets/Vanguard/Runtime/UI/Localization.cs @@ -68,6 +68,10 @@ namespace Vanguard.UI ["Сброс для способности"] = "Discard for ability", ["Сбросить: "] = "Discard: ", ["Без сброса"] = "No discard", + ["Сбросить карту в руке"] = "Discard a card from hand", + ["Выберите карту в руке для сброса."] = "Choose a card in hand to discard.", + ["Perfect Guard: выберите карту в руке для сброса."] = "Perfect Guard: choose a card in hand to discard.", + ["Способность"] = "Ability", ["Guard без сброса"] = "Guard without discard", ["Perfect Guard: сбросить "] = "Perfect Guard: discard ", ["Защититься"] = "Guard", diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 4ef1290..30fb605 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -987,16 +987,16 @@ Battle; Boot и общие сервисы остаются персистент Атака по-прежнему запрещена. - Смена: в фазе Ride предлагается и карта того же ранга, и следующего (раньше legal-команды содержали только `+1`, хотя движок разрешал same - grade). В панели действий есть явная кнопка «Сменить: карта». + grade). Кнопки «Сменить: карта» есть в панели действий и в «Фазах». - Маллиган: кнопка динамическая — без выбора «Закончить муллиган», с выбором «Заменить X карт». - Способности: `ActivateAbilityIntent` группируются по карте; в UI одна - кнопка «Активировать способность». Если целей/вариантов несколько — - отдельный overlay, затем выбор карты для сброса (без duplicate-кнопок). + кнопка «Активировать способность». Если вариантов несколько — overlay + выбора способности; карта для сброса выбирается кликом по руке. - Guard: варианты Perfect Guard/сброса показываются отдельным overlay; случайный первый интент больше не выбирается. В фазе защиты есть - «Защититься» (при наличии допустимых карт) и «Не защищаться». Сброс - выбирается из карт в руке, а не по первому интенту. + «Защититься» (при наличии допустимых карт) и «Не защищаться». Карта + сброса выбирается кликом по карте в руке. - Правую панель действий можно временно скрыть/показать кнопкой в верхней строке, чтобы видеть поле. При скрытии панель фаз и UI противника сдвигаются вправо. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index a9f3bb3..ccbbfec 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -84,13 +84,13 @@ `P/C`. - Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом ходу переход в Battle не предлагается; атака по-прежнему запрещена. - В панели действий отображается явная кнопка «Сменить: карта». + Кнопки «Сменить: карта» есть и в панели действий, и в панели «Фазы». - Способности: кнопка «Активировать способность» одна на карту; при - нескольких вариантах открывается overlay выбора, затем — выбор карты - для сброса. Perfect Guard также спрашивает карту для сброса отдельным - overlay, а не выбирает случайную. + нескольких вариантах открывается overlay выбора способности. Карта для + сброса выбирается **кликом по карте в руке**, а не пунктом меню. + Perfect Guard также просит выбрать карту сброса в руке. - В фазе защиты доступны «Защититься» (если есть допустимые карты) и - «Не защищаться»; выбор карты защиты и сброса идёт через overlay. + «Не защищаться»; карта защиты и карта сброса выбираются в руке. - Локальный standalone работает в фоне (`runInBackground`), чтобы heartbeat WebSocket не останавливался при потере фокуса. - При потере соединения весь бой блокируется полноэкранным overlay