From 7bb6bcb97587990dcefd67a6900c9b4d7ed32f27 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 23 Oct 2026 18:54:58 +0500 Subject: [PATCH] Add unified required-action prompt overlay --- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 142 ++++++++++++++++++ .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 48 ++++++ docs/HANDOFF.md | 7 +- docs/SESSION-STATE.md | 6 +- 4 files changed, 201 insertions(+), 2 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 6b33187..c49744c 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -111,6 +111,10 @@ namespace Vanguard.UI private RectTransform _phasePanel; private RectTransform _combatStats; private Text _combatStatsText; + private RectTransform _actionPrompt; + private Text _actionPromptTitle; + private Text _actionPromptHint; + private string _actionPromptKey; private bool _autoFinishMulligan; private float _autoFinishMulliganAt; private bool _autoEndBattleArmed; @@ -168,6 +172,8 @@ namespace Vanguard.UI _concedeLabel = _concede.GetComponentInChildren(); UiKit.SetPreferredHeight(_concede, 44f, 0f); + BuildActionPrompt(); + var right = UiKit.CreatePanel(_root, "IntentPanel", Panel); _intentPanel = right; right.anchorMin = new Vector2(1f, 0f); @@ -266,6 +272,7 @@ namespace Vanguard.UI _reconnect.gameObject.SetActive(false); _result.gameObject.SetActive(false); _pendingIntents.Clear(); + HideActionPrompt(); _discardChoiceActive = false; _discardAbilityChoices.Clear(); _discardGuardChoices.Clear(); @@ -332,6 +339,7 @@ namespace Vanguard.UI } _view = null; + HideActionPrompt(); _root.gameObject.SetActive(false); _result.gameObject.SetActive(false); _feedback.gameObject.SetActive(false); @@ -357,6 +365,7 @@ namespace Vanguard.UI UpdateClock(); UpdateFeedback(); + RefreshActionPrompt(); SanitizeInputLocks(); SyncAbilityPrompt(); SyncChoicePrompt(); @@ -2309,6 +2318,139 @@ namespace Vanguard.UI OpenZone(BoardZone.Deck, pile.Player); } + private void BuildActionPrompt() + { + _actionPrompt = UiKit.CreatePanel( + _root, + "ActionPrompt", + new Color(0.025f, 0.04f, 0.065f, 0.88f)); + _actionPrompt.anchorMin = new Vector2(0.5f, 1f); + _actionPrompt.anchorMax = new Vector2(0.5f, 1f); + _actionPrompt.pivot = new Vector2(0.5f, 1f); + _actionPrompt.anchoredPosition = new Vector2(0f, -98f); + _actionPrompt.sizeDelta = new Vector2(760f, 72f); + var image = _actionPrompt.GetComponent(); + if (image != null) + image.raycastTarget = false; + UiKit.AddVerticalLayout(_actionPrompt, 8, 2); + + _actionPromptTitle = UiKit.CreateText( + _actionPrompt, + "ActionPromptTitle", + string.Empty, + 16, + new Color(1f, 0.80f, 0.42f), + TextAnchor.MiddleCenter); + _actionPromptTitle.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(_actionPromptTitle, 24f); + + _actionPromptHint = UiKit.CreateText( + _actionPrompt, + "ActionPromptHint", + string.Empty, + 14, + new Color(0.78f, 0.86f, 0.98f), + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(_actionPromptHint, 24f); + _actionPrompt.gameObject.SetActive(false); + } + + private void RefreshActionPrompt() + { + if (_actionPrompt == null || _view == null || _view.IsFinished) + { + HideActionPrompt(); + return; + } + + string title = null; + string hint = null; + + if (_discardChoiceActive) + { + title = "СБРОС КАРТЫ"; + hint = string.IsNullOrWhiteSpace(_discardPrompt) + ? "Выберите карту в руке для сброса." + : _discardPrompt; + } + else if (_pendingIntents.Count > 0) + { + title = "ВЫБОР ЦЕЛИ"; + hint = _pendingIntents.Any(intent => intent is ResolveTriggerIntent) + ? "Выберите цель силы или эффекта триггера." + : "Выберите цель на поле."; + } + else if (_view.Ability != null && _view.ActivePlayer == _view.You) + { + title = "СПОСОБНОСТЬ"; + hint = "Разыграть способность «" + CardName(_view.Ability.PrintingId) + "»?"; + } + else if (_view.Order != null && _view.ActivePlayer == _view.You) + { + title = "ПОРЯДОК ТРИГГЕРОВ"; + hint = "Выберите, какой эффект разрешить первым."; + } + else if (_view.Trigger != null && _view.ActivePlayer == _view.You) + { + title = "ТРИГГЕР"; + hint = "Выберите, как применить эффект триггера."; + } + else if (_view.Choice != null && _view.ActivePlayer == _view.You) + { + title = IsRearGuardChoice() ? "ВЫБОР КРУГА" : "ВЫБОР"; + hint = _view.Choice.Prompt; + } + else if (_view.ActivePlayer != _view.You) + { + HideActionPrompt(); + return; + } + else if (_view.Phase == GamePhase.Mulligan) + { + title = "МУЛИГАН"; + hint = "Выберите карты в руке, затем замените их или закончите муллиган."; + } + else if (_view.Phase == GamePhase.Ride) + { + title = "СМЕНА"; + hint = "Выберите карту для смены или пропустите смену."; + } + else if (_view.Step == GameStep.Guard) + { + title = "ЗАЩИТА"; + hint = "Выберите защиту или не защищайтесь."; + } + else if (_view.Phase == GamePhase.Battle) + { + title = "БОЙ"; + hint = "Выберите атакующего или завершите ход."; + } + else + { + HideActionPrompt(); + return; + } + + var key = (title ?? string.Empty) + "\n" + (hint ?? string.Empty); + if (string.Equals(_actionPromptKey, key, StringComparison.Ordinal) + && _actionPrompt.gameObject.activeSelf) + return; + + _actionPromptKey = key; + _actionPromptTitle.text = title ?? string.Empty; + _actionPromptHint.text = hint ?? string.Empty; + _actionPrompt.gameObject.SetActive(true); + } + + private void HideActionPrompt() + { + if (_actionPrompt == null) + return; + + _actionPromptKey = null; + _actionPrompt.gameObject.SetActive(false); + } + private void BuildPhasePanel() { _phasePanel = UiKit.CreatePanel( diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index dcdfac9..d10dc42 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -380,6 +380,54 @@ namespace Vanguard.Tests } } + [Test] + public void Action_Prompt_Shows_Current_Required_Action() + { + 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("Action Prompt Test Canvas"); + + try + { + var battle = new BattleView(canvas.transform, meta, () => { }); + var match = new MatchView + { + You = 0, + ActivePlayer = 0, + TurnNumber = 2, + Phase = GamePhase.Ride, + LegalIntents = new List(), + }; + typeof(BattleView) + .GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(battle, match); + var refresh = typeof(BattleView) + .GetMethod("RefreshActionPrompt", BindingFlags.Instance | BindingFlags.NonPublic)!; + refresh.Invoke(battle, null); + + var prompt = Resources.FindObjectsOfTypeAll() + .First(go => go.name == "ActionPrompt"); + Assert.IsTrue(prompt.activeSelf); + var title = Resources.FindObjectsOfTypeAll() + .First(text => text.name == "ActionPromptTitle"); + Assert.AreEqual("СМЕНА", title.text); + + match.Phase = GamePhase.Main; + refresh.Invoke(battle, null); + Assert.IsFalse(prompt.activeSelf); + } + 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/docs/HANDOFF.md b/docs/HANDOFF.md index 747e486..1fc3cf6 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1017,6 +1017,11 @@ Battle; Boot и общие сервисы остаются персистент свободный круг автоматически: сервер предлагает `AskEmptyRearGuardChoice`, клиент показывает оверлей с кругами и подсвечивает свободные RC. Боковая панель пока оставлена для остальных действий. +- Добавлен общий верхний `ActionPrompt` с текущим обязательным действием: + муллиган, смена, защита, бой, выбор карты/круга/цели, триггер, порядок + триггеров и опциональная способность. Промпт информационный, не + перехватывает клики; обновляется из снапшота и не показывается в ход + противника. - Trigger-зона противника зеркальна: над его колодой в сторону центра, а не за дальним краем. - Карты на столе и в руке масштабируются общим `cardScale = 1.25` @@ -1039,4 +1044,4 @@ Battle; Boot и общие сервисы остаются персистент (карты, которые сервер должен предложить в фазе Ride). Смотреть: `ssh home "docker compose -f ~/VanguardReMaster/docker-compose.yml logs -f server"`. - Проверки: сервер **142** правил + **45** серверных, `validate` и - `translations check` OK; клиент EditMode **33/33**. + `translations check` OK; клиент EditMode **34/34**. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 10c4f62..7c77d2f 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -141,6 +141,10 @@ `AskEmptyRearGuardChoice`, клиент показывает оверлей с кругами и подсвечивает свободные RC — первый круг больше не выбирается автоматически. Сама панель пока оставлена. +- Общий верхний `ActionPrompt` показывает, что сейчас требуется: муллиган, + смена, защита, бой, выбор карты/круга/цели, триггер, порядок триггеров, + опциональная способность. Он не перехватывает клики и скрыт в ход + противника. - Бой: динамическая плашка `POWER X vs SHIELD Y` (Power атакующего против Power защищающегося + GuardShield), пересчитывается на каждом снимке, в том числе после Guard. От атакующего к цели идёт затухающая 3D-стрелка, @@ -214,7 +218,7 @@ Standalone Windows: -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **33/33** EditMode (сборка всех экранов, импорт арта, +Клиентские проверки: **34/34** EditMode (сборка всех экранов, импорт арта, перевод статических/динамических строк, live-переключение языка, рендер 3D-доски с уроном и отдыхающей картой, геометрия поля, правила перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,