From 489d4eb394d217af8b03ebed4b8ee68fabad8a2f Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 23 Oct 2026 16:41:54 +0500 Subject: [PATCH] Add explicit ride controls, dynamic mulligan and hideable action panel --- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 175 +++++++++++++++--- .../Vanguard/Runtime/UI/Localization.cs | 5 + docs/HANDOFF.md | 13 +- docs/SESSION-STATE.md | 9 +- .../Vanguard.Rules.Tests/GameEngineTests.cs | 22 +++ 5 files changed, 190 insertions(+), 34 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index d9b0cf5..c8d41a2 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -83,6 +83,9 @@ namespace Vanguard.UI private ZoneBlockStripView _selfZones; private ZoneBlockStripView _opponentZones; private CardActionMenuView _cardMenu; + private RectTransform _intentPanel; + private Text _intentPanelToggleLabel; + private bool _intentPanelVisible = true; private RectTransform _phasePanel; private RectTransform _combatStats; private Text _combatStatsText; @@ -128,11 +131,23 @@ namespace Vanguard.UI top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight); UiKit.SetPreferredHeight(_clock, 44f, 0f); + var toggle = UiKit.CreateButton( + top, + "ToggleIntentPanel", + "Скрыть панель", + ToggleIntentPanel, + 16); + var toggleElement = UiKit.SetPreferredHeight(toggle, 44f, 0f); + toggleElement.preferredWidth = 168f; + _intentPanelToggleLabel = toggle.GetComponentInChildren(); + UiKit.MakeLiteral(_intentPanelToggleLabel); + _concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18); _concedeLabel = _concede.GetComponentInChildren(); UiKit.SetPreferredHeight(_concede, 44f, 0f); var right = UiKit.CreatePanel(_root, "IntentPanel", Panel); + _intentPanel = right; right.anchorMin = new Vector2(1f, 0f); right.anchorMax = new Vector2(1f, 1f); right.pivot = new Vector2(1f, 0.5f); @@ -236,6 +251,14 @@ namespace Vanguard.UI _autoFinishMulliganAt = 0f; _autoEndBattleArmed = false; _autoEndBattleAt = 0f; + _intentPanelVisible = true; + if (_intentPanel != null) + _intentPanel.gameObject.SetActive(true); + if (_intentPanelToggleLabel != null) + { + _intentPanelToggleLabel.text = "Скрыть панель"; + UiKit.MakeLiteral(_intentPanelToggleLabel); + } _inputLock.ReleaseAll(); _feedbackLockHeld = false; _logLines.Clear(); @@ -847,6 +870,20 @@ namespace Vanguard.UI } } + private void ToggleIntentPanel() + { + _intentPanelVisible = !_intentPanelVisible; + if (_intentPanel != null) + _intentPanel.gameObject.SetActive(_intentPanelVisible); + if (_intentPanelToggleLabel != null) + { + _intentPanelToggleLabel.text = _intentPanelVisible + ? "Скрыть панель" + : "Показать панель"; + UiKit.MakeLiteral(_intentPanelToggleLabel); + } + } + private void OnConcedeClicked() { if (_view == null || _finished) @@ -1247,9 +1284,26 @@ namespace Vanguard.UI UiKit.SetPreferredHeight(button, 46f); } - private void AddGuardButton(IReadOnlyList guards) + private void AddRideButtons(IReadOnlyList rides) { - if (guards == null || guards.Count == 0) + foreach (var ride in rides + .GroupBy(item => item.PrintingId) + .Select(group => group.First())) + { + var captured = ride; + var button = UiKit.CreateButton( + _intents, + "Ride_" + captured.PrintingId, + "Сменить: " + CardName(captured.PrintingId), + () => OnIntentClicked(captured), + 16); + button.interactable = !_inputLock.IsLocked; + UiKit.SetPreferredHeight(button, 46f); + } + } + + private void AddGuardButton(IReadOnlyList guards) + { if (guards == null || guards.Count == 0) return; var button = UiKit.CreateButton( @@ -1327,19 +1381,44 @@ namespace Vanguard.UI if (distinct.Count == 0) return; - if (distinct.Count == 1) + var free = distinct.FirstOrDefault( + variant => string.IsNullOrEmpty(variant.DiscardPrintingId)); + var discardVariants = distinct + .Where(variant => !string.IsNullOrEmpty(variant.DiscardPrintingId)) + .ToList(); + if (discardVariants.Count == 0) { - OnIntentClicked(distinct[0]); + OnIntentClicked(free ?? distinct[0]); return; } - var items = distinct - .Select(variant => new CardActionMenuItem( - string.IsNullOrEmpty(variant.DiscardPrintingId) - ? "Без сброса" - : "Сбросить: " + CardName(variant.DiscardPrintingId), - () => OnIntentClicked(variant))) - .ToList(); + 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))); + } + + if (items.Count == 0) + { + items.AddRange(discardVariants.Select(variant => + new CardActionMenuItem( + "Сбросить: " + CardName(variant.DiscardPrintingId), + () => OnIntentClicked(variant)))); + } + ShowChoiceMenu("Сброс для способности", items); } @@ -1354,29 +1433,48 @@ namespace Vanguard.UI if (distinct.Count == 0) return; - if (distinct.Count > 1) - { - distinct = distinct - .Where(guard => !string.IsNullOrEmpty(guard.DiscardPrintingId)) - .ToList(); - } - - if (distinct.Count == 1) + var discardVariants = distinct + .Where(guard => !string.IsNullOrEmpty(guard.DiscardPrintingId)) + .ToList(); + if (discardVariants.Count == 0) { OnIntentClicked(distinct[0]); return; } - var items = distinct - .Select(guard => new CardActionMenuItem( - string.IsNullOrEmpty(guard.DiscardPrintingId) - ? "Guard без сброса" - : "Perfect Guard: сбросить " + CardName(guard.DiscardPrintingId), - () => OnIntentClicked(guard))) - .ToList(); + 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); } + private List PlayerHand() + { + var player = _view?.Players.FirstOrDefault(item => item.Index == _view.You); + return player?.Hand ?? new List(); + } + private void ShowChoiceMenu( string title, IReadOnlyList items, @@ -2204,10 +2302,12 @@ namespace Vanguard.UI 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) && intent is not ActivateAbilityIntent - && intent is not GuardIntent) + && intent is not GuardIntent + && intent is not RideIntent) .ToList(); var global = playable .Where(intent => !IsBoardIntent(intent) @@ -2274,11 +2374,27 @@ namespace Vanguard.UI var exchange = UiKit.CreateButton( _intents, "MulliganExchange", - "Маллиган: " + _mulliganSelected.Count + " карт", + "Заменить " + _mulliganSelected.Count + " карт", SendMulligan, 16); UiKit.SetPreferredHeight(exchange, 44f); } + else + { + var finishMulligan = (view.LegalIntents ?? new List()) + .OfType() + .FirstOrDefault(); + if (finishMulligan != null) + { + var finishButton = UiKit.CreateButton( + _intents, + "MulliganFinish", + "Закончить муллиган", + () => OnIntentClicked(finishMulligan), + 16); + UiKit.SetPreferredHeight(finishButton, 44f); + } + } } if (IsAbilityDamageTargeting()) @@ -2325,6 +2441,9 @@ namespace Vanguard.UI if (guards.Count > 0) AddGuardButton(guards); + if (rides.Count > 0) + AddRideButtons(rides); + foreach (var intent in global) AddIntentButton(intent); diff --git a/client/Assets/Vanguard/Runtime/UI/Localization.cs b/client/Assets/Vanguard/Runtime/UI/Localization.cs index 98d9acd..ebbe870 100644 --- a/client/Assets/Vanguard/Runtime/UI/Localization.cs +++ b/client/Assets/Vanguard/Runtime/UI/Localization.cs @@ -74,6 +74,11 @@ namespace Vanguard.UI ["Защититься: "] = "Guard: ", ["Защита"] = "Guard", ["Не защищаться"] = "Do not guard", + ["Скрыть панель"] = "Hide panel", + ["Показать панель"] = "Show panel", + ["Сменить: "] = "Ride: ", + ["Заменить "] = "Replace ", + ["Закончить муллиган"] = "Finish mulligan", ["Действия"] = "Actions", ["Переместить"] = "Move", ["Подробнее"] = "Details", diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index ffd787b..767a833 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **186/186** (141 правил/контрактов + 45 сервер) | +| Тесты сервера | **187/187** (142 правил/контрактов + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | @@ -987,17 +987,22 @@ Battle; Boot и общие сервисы остаются персистент Атака по-прежнему запрещена. - Смена: в фазе Ride предлагается и карта того же ранга, и следующего (раньше legal-команды содержали только `+1`, хотя движок разрешал same - grade). + grade). В панели действий есть явная кнопка «Сменить: карта». +- Маллиган: кнопка динамическая — без выбора «Закончить муллиган», с + выбором «Заменить X карт». - Способности: `ActivateAbilityIntent` группируются по карте; в UI одна кнопка «Активировать способность». Если целей/вариантов несколько — отдельный overlay, затем выбор карты для сброса (без duplicate-кнопок). - Guard: варианты Perfect Guard/сброса показываются отдельным overlay; случайный первый интент больше не выбирается. В фазе защиты есть - «Защититься» (при наличии допустимых карт) и «Не защищаться». + «Защититься» (при наличии допустимых карт) и «Не защищаться». Сброс + выбирается из карт в руке, а не по первому интенту. +- Правую панель действий можно временно скрыть/показать кнопкой в верхней + строке, чтобы видеть поле. - Камера боя: `(0, 4.2, -8)` → цель `(0, 0, 0)`, только наклон по X; поле по центру, без бокового смещения. - UI: reference-разрешение **1920×1080**, CanvasScaler `matchWidthOrHeight = 0.5`; standalone принудительно `1920×1080 FullScreenWindow`, `defaultIsNativeResolution = 0`. -- Проверки: сервер **141** правил + **45** серверных, `validate` и +- Проверки: сервер **142** правил + **45** серверных, `validate` и `translations check` OK; клиент EditMode **30/30**. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index d4c17ca..131690e 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **141** правил + **45** серверных, CI зелёный. +- Тесты: **142** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Документация: `docs/server-api.md`, `docs/protocol.md`, `docs/game-rules.md`, `docs/HANDOFF.md`. @@ -84,12 +84,15 @@ `P/C`. - Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом ходу переход в Battle не предлагается; атака по-прежнему запрещена. + В панели действий отображается явная кнопка «Сменить: карта». - Способности: кнопка «Активировать способность» одна на карту; при нескольких вариантах открывается overlay выбора, затем — выбор карты для сброса. Perfect Guard также спрашивает карту для сброса отдельным overlay, а не выбирает случайную. - В фазе защиты доступны «Защититься» (если есть допустимые карты) и «Не защищаться»; выбор карты защиты и сброса идёт через overlay. +- Правую панель действий можно временно скрыть кнопкой «Скрыть панель» + в верхней строке; поле остаётся видимым, вернуть — «Показать панель». - Статистика карт — отдельное UI-наложение: своя панель снизу слева, противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер` кликабельны и открывают общий горизонтальный `CardListView`; из списка @@ -105,7 +108,9 @@ выбранная карта переворачивается рубашкой. Heal выбирает карту урона; Counter Blast — открытую карту до активации способности. - Маллиган: один раз на игрока, оба игрока; выбранные карты уходят в низ - колоды, добираются новые, затем shuffle. Клик по руке выбирает карты. + колоды, добираются новые, затем shuffle. Клик по руке выбирает карты; + кнопка динамическая: без выбора — «Закончить муллиган», с выбором — + «Заменить X карт». - Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх стола; ввод заблокирован до завершения через `InputLockService`, очередь событий + кнопка «Пропустить анимации». Длительности и скорости карт — diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index 59a0cf1..5f3784c 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -131,6 +131,28 @@ public class GameEngineTests Assert.True(engine.Execute(new RideCommand(Conroe)).Accepted); } + [Fact] + public void Ride_Can_Reach_Second_Grade_On_Next_Turn() + { + var engine = StartedEngine(); + Give(engine, 0, Marron); + Assert.True(engine.Execute(new RideCommand(Marron)).Accepted); + + engine.Execute(new EndTurnCommand()); + engine.Execute(new SkipRideCommand()); + engine.Execute(new EndTurnCommand()); + + Give(engine, 0, Gallatin); + Assert.Contains( + engine.LegalCommands(), + command => command is RideCommand ride && ride.PrintingId == Gallatin); + + Assert.True(engine.Execute(new RideCommand(Gallatin)).Accepted); + Assert.Equal( + 2, + engine.State.Catalog.CardOf(engine.State.Players[0].Vanguard!.PrintingId).Grade); + } + [Fact] public void Both_Players_Mulligan_Once_Then_First_Player_Starts() {