Show optional ability prompt overlay and fix board lock accumulation
CI / test (push) Has been cancelled Details

This commit is contained in:
MpakobecEx 2026-10-23 18:38:42 +05:00
parent ba932c0d17
commit 69f2121e16
5 changed files with 226 additions and 5 deletions

View File

@ -297,6 +297,7 @@ namespace Vanguard.Board3D
if (_inputLock != null
&& _animationConfig != null
&& _boardLockReleaseAt <= 0f
&& AnyCardAnimating())
{
_inputLock.Acquire("board");

View File

@ -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<IntentPayload>())
.OfType<ResolveAbilityIntent>()
.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)

View File

@ -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<IntentPayload>
{
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<GameObject>()
.First(go => go.name == "AbilityOverlay");
Assert.IsTrue(overlay.activeSelf);
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Ability_Активировать"));
Assert.IsTrue(Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Ability_Пропустить"));
typeof(BattleView)
.GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(battle, new object[] { match });
Assert.IsFalse(Resources.FindObjectsOfTypeAll<GameObject>()
.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<GameObject>()
.Where(go => go.scene.IsValid() && go.name == name)

View File

@ -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**.

View File

@ -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-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,