diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index cba99f6..98abdb3 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -297,6 +297,7 @@ namespace Vanguard.Board3D if (_inputLock != null && _animationConfig != null + && _boardLockReleaseAt <= 0f && AnyCardAnimating()) { _inputLock.Acquire("board"); diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 3a077e6..33e6f9d 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -67,6 +67,10 @@ namespace Vanguard.UI private RectTransform _reconnectOverlay; private Text _reconnectOverlayText; private Text _reconnectOverlayHint; + private RectTransform _abilityOverlay; + private Text _abilityOverlayName; + private RectTransform _abilityOverlayButtons; + private int _abilityPromptInstanceId; private MatchConnectionInfo _info; private MatchView _view; private bool _connecting; @@ -226,6 +230,7 @@ namespace Vanguard.UI BuildOverlaysAndZones(); BuildPhasePanel(); BuildCombatStats(); + BuildAbilityOverlay(); BuildReconnectOverlay(); _cardMenu = new CardActionMenuView(); _cardMenu.Build(_root); @@ -295,6 +300,7 @@ namespace Vanguard.UI _board3D?.Clear(); _board3D?.SetVisible(true); _overlays?.CloseAll(); + HideAbilityOverlay(); _selfZones?.Update(null, self: true); _opponentZones?.Update(null, self: false); _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; @@ -326,6 +332,7 @@ namespace Vanguard.UI _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); _overlays?.CloseAll(); + HideAbilityOverlay(); _cardMenu?.Hide(); _combatStats?.gameObject.SetActive(false); _board3D?.SetVisible(false); @@ -344,6 +351,7 @@ namespace Vanguard.UI UpdateClock(); UpdateFeedback(); SanitizeInputLocks(); + SyncAbilityPrompt(); UpdateAutoPhases(); } @@ -364,6 +372,8 @@ namespace Vanguard.UI _inputLock.Release("overlay"); if (_cardMenu == null || !_cardMenu.IsVisible) _inputLock.Release("card-menu"); + if (_abilityOverlay == null || !_abilityOverlay.gameObject.activeSelf) + _inputLock.Release("ability"); if (!_feedbackPlaying && _feedbackQueue.Count == 0) { _feedbackLockHeld = false; @@ -725,6 +735,7 @@ namespace Vanguard.UI } Render(); + SyncAbilityPrompt(); } private void OnEvents(ServerEvents events) @@ -1145,6 +1156,137 @@ namespace Vanguard.UI ApplyIntentPanelLayout(); } + private void BuildAbilityOverlay() + { + _abilityOverlay = UiKit.CreatePanel( + _root, + "AbilityOverlay", + new Color(0f, 0f, 0f, 0.72f)); + var card = UiKit.CreateCenteredCard( + _abilityOverlay, + "AbilityCard", + new Vector2(640f, 300f), + Panel); + UiKit.AddVerticalLayout(card, 22, 10); + + var title = UiKit.CreateText( + card, + "AbilityTitle", + "Способность", + 22, + new Color(1f, 0.78f, 0.42f), + TextAnchor.MiddleCenter); + title.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(title, 34f); + + _abilityOverlayName = UiKit.CreateText( + card, + "AbilityName", + string.Empty, + 26, + Color.white, + TextAnchor.MiddleCenter); + _abilityOverlayName.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(_abilityOverlayName, 46f); + + var hint = UiKit.CreateText( + card, + "AbilityHint", + "Активировать способность?", + 17, + new Color(0.78f, 0.84f, 0.96f), + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(hint, 32f); + + _abilityOverlayButtons = UiKit.CreatePanel( + card, "AbilityButtons", Color.clear); + UiKit.AddHorizontalLayout(_abilityOverlayButtons, 0, 10); + UiKit.SetPreferredHeight(_abilityOverlayButtons, 48f); + _abilityOverlay.gameObject.SetActive(false); + } + + private void SyncAbilityPrompt() + { + if (_abilityOverlay == null || _sending) + return; + + var ability = _view?.Ability; + if (ability == null + || _view == null + || _view.ActivePlayer != _view.You) + { + HideAbilityOverlay(); + return; + } + + if (_abilityOverlay.gameObject.activeSelf + && _abilityPromptInstanceId == ability.InstanceId) + return; + + var intents = (_view.LegalIntents ?? new List()) + .OfType() + .ToList(); + var accept = intents.FirstOrDefault(intent => intent.Accept); + var skip = intents.FirstOrDefault(intent => !intent.Accept); + if (accept == null && skip == null) + return; + + _abilityPromptInstanceId = ability.InstanceId; + _abilityOverlayName.text = CardName(ability.PrintingId); + UiKit.MakeLiteral(_abilityOverlayName); + ClearChildren(_abilityOverlayButtons); + + if (accept != null) + { + AddAbilityOverlayButton( + "Активировать", + accept, + new Color(0.20f, 0.42f, 0.78f)); + } + + if (skip != null) + { + AddAbilityOverlayButton( + "Пропустить", + skip, + new Color(0.24f, 0.28f, 0.36f)); + } + + _abilityOverlay.SetAsLastSibling(); + _abilityOverlay.gameObject.SetActive(true); + _inputLock.Acquire("ability"); + } + + private void AddAbilityOverlayButton( + string label, + ResolveAbilityIntent intent, + Color color) + { + var captured = intent; + var button = UiKit.CreateButton( + _abilityOverlayButtons, + "Ability_" + label, + label, + () => + { + HideAbilityOverlay(); + OnIntentClicked(captured, ignoreInputLock: true); + }, + 18); + button.image.color = color; + UiKit.SetPreferredHeight(button, 46f); + } + + private void HideAbilityOverlay() + { + if (_abilityOverlay == null) + return; + + _abilityPromptInstanceId = 0; + _abilityOverlay.gameObject.SetActive(false); + _inputLock.Release("ability"); + } + private void BuildReconnectOverlay() { _reconnectOverlay = UiKit.CreatePanel( @@ -2734,7 +2876,8 @@ namespace Vanguard.UI var global = playable .Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent) - && intent is not ActivateAbilityIntent) + && intent is not ActivateAbilityIntent + && intent is not ResolveAbilityIntent) .ToList(); if (_pendingIntents.Count > 0) diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 236c341..329406b 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -258,6 +258,73 @@ namespace Vanguard.Tests } } + [Test] + public void Optional_Ability_Shows_Overlay_Instead_Of_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("Ability Test Canvas"); + + try + { + var battle = new BattleView(canvas.transform, meta, () => { }); + var match = new MatchView + { + You = 0, + ActivePlayer = 0, + Ability = new AbilityView + { + Player = 0, + InstanceId = 42, + PrintingId = "BT01/003EN", + Optional = true, + }, + LegalIntents = new List + { + new ResolveAbilityIntent { Accept = true }, + new ResolveAbilityIntent { Accept = false }, + new ConcedeIntent(), + }, + }; + + typeof(BattleView) + .GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(battle, match); + typeof(BattleView) + .GetMethod("SyncAbilityPrompt", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(battle, null); + + var overlay = Resources.FindObjectsOfTypeAll() + .First(go => go.name == "AbilityOverlay"); + Assert.IsTrue(overlay.activeSelf); + Assert.IsTrue(Resources.FindObjectsOfTypeAll() + .Any(go => go.name == "Ability_Активировать")); + Assert.IsTrue(Resources.FindObjectsOfTypeAll() + .Any(go => go.name == "Ability_Пропустить")); + + typeof(BattleView) + .GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(battle, new object[] { match }); + Assert.IsFalse(Resources.FindObjectsOfTypeAll() + .Any(go => go.name == "Intent_ResolveAbilityIntent")); + + typeof(BattleView) + .GetMethod("HideAbilityOverlay", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(battle, null); + Assert.IsFalse(overlay.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 e52afd8..c3b18c5 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1006,8 +1006,14 @@ Battle; Boot и общие сервисы остаются персистент блокируется feedback/overlay-локом; при реконнекте авто-завершение взводится повторно из снапшота. - InputLock чистит зависшие причины, если owning-состояние уже неактивно - (нет reconnect/overlay/card-menu/feedback). Это убирает блокировку + (нет reconnect/overlay/ability/card-menu/feedback). Это убирает блокировку «Сменить» после хода противника, когда сервер уже прислал ride-интенты. + Отдельно исправлено накопление `board`-лока: `GameBoard3D` больше не + делает повторный `Acquire` на каждом снапшоте во время анимации. +- Опциональные AUTO-способности (`PendingAbility`, например Barcgal после + рейда на Marron) показываются модальным оверлеем с названием карты и + кнопками «Активировать» / «Пропустить»; из боковой панели их кнопки + убраны. Боковая панель пока оставлена для остальных действий. - Trigger-зона противника зеркальна: над его колодой в сторону центра, а не за дальним краем. - Карты на столе и в руке масштабируются общим `cardScale = 1.25` @@ -1030,4 +1036,4 @@ Battle; Boot и общие сервисы остаются персистент (карты, которые сервер должен предложить в фазе Ride). Смотреть: `ssh home "docker compose -f ~/VanguardReMaster/docker-compose.yml logs -f server"`. - Проверки: сервер **142** правил + **45** серверных, `validate` и - `translations check` OK; клиент EditMode **31/31**. + `translations check` OK; клиент EditMode **32/32**. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 8cece45..bd550c5 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -133,7 +133,11 @@ показывают имя карты и держатся 1.5–2 с. 3D-карты плавно снапятся, поворачиваются в Rest и переворачиваются; подсветка целей и точки броска обновляются на каждом снимке. Зависшие причины `InputLockService` - снимаются, когда reconnect/overlay/card-menu/feedback уже неактивны. + снимаются, когда reconnect/overlay/ability/card-menu/feedback уже + неактивны; `board`-лок больше не накапливается на каждом снапшоте. +- Опциональные AUTO-способности (Barcgal после рейда Marron) открывают + модальный оверлей «Способность» с «Активировать»/«Пропустить»; в боковой + панели этих кнопок нет. Сама панель пока оставлена. - Бой: динамическая плашка `POWER X vs SHIELD Y` (Power атакующего против Power защищающегося + GuardShield), пересчитывается на каждом снимке, в том числе после Guard. От атакующего к цели идёт затухающая 3D-стрелка, @@ -207,7 +211,7 @@ Standalone Windows: -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **31/31** EditMode (сборка всех экранов, импорт арта, +Клиентские проверки: **32/32** EditMode (сборка всех экранов, импорт арта, перевод статических/динамических строк, live-переключение языка, рендер 3D-доски с уроном и отдыхающей картой, геометрия поля, правила перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,