Add explicit ride controls, dynamic mulligan and hideable action panel
CI / test (push) Successful in 1m55s Details

This commit is contained in:
MpakobecEx 2026-10-23 16:41:54 +05:00
parent a3d2157925
commit 489d4eb394
5 changed files with 190 additions and 34 deletions

View File

@ -83,6 +83,9 @@ namespace Vanguard.UI
private ZoneBlockStripView _selfZones; private ZoneBlockStripView _selfZones;
private ZoneBlockStripView _opponentZones; private ZoneBlockStripView _opponentZones;
private CardActionMenuView _cardMenu; private CardActionMenuView _cardMenu;
private RectTransform _intentPanel;
private Text _intentPanelToggleLabel;
private bool _intentPanelVisible = true;
private RectTransform _phasePanel; private RectTransform _phasePanel;
private RectTransform _combatStats; private RectTransform _combatStats;
private Text _combatStatsText; private Text _combatStatsText;
@ -128,11 +131,23 @@ namespace Vanguard.UI
top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight); top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight);
UiKit.SetPreferredHeight(_clock, 44f, 0f); 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<Text>();
UiKit.MakeLiteral(_intentPanelToggleLabel);
_concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18); _concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18);
_concedeLabel = _concede.GetComponentInChildren<Text>(); _concedeLabel = _concede.GetComponentInChildren<Text>();
UiKit.SetPreferredHeight(_concede, 44f, 0f); UiKit.SetPreferredHeight(_concede, 44f, 0f);
var right = UiKit.CreatePanel(_root, "IntentPanel", Panel); var right = UiKit.CreatePanel(_root, "IntentPanel", Panel);
_intentPanel = right;
right.anchorMin = new Vector2(1f, 0f); right.anchorMin = new Vector2(1f, 0f);
right.anchorMax = new Vector2(1f, 1f); right.anchorMax = new Vector2(1f, 1f);
right.pivot = new Vector2(1f, 0.5f); right.pivot = new Vector2(1f, 0.5f);
@ -236,6 +251,14 @@ namespace Vanguard.UI
_autoFinishMulliganAt = 0f; _autoFinishMulliganAt = 0f;
_autoEndBattleArmed = false; _autoEndBattleArmed = false;
_autoEndBattleAt = 0f; _autoEndBattleAt = 0f;
_intentPanelVisible = true;
if (_intentPanel != null)
_intentPanel.gameObject.SetActive(true);
if (_intentPanelToggleLabel != null)
{
_intentPanelToggleLabel.text = "Скрыть панель";
UiKit.MakeLiteral(_intentPanelToggleLabel);
}
_inputLock.ReleaseAll(); _inputLock.ReleaseAll();
_feedbackLockHeld = false; _feedbackLockHeld = false;
_logLines.Clear(); _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() private void OnConcedeClicked()
{ {
if (_view == null || _finished) if (_view == null || _finished)
@ -1247,9 +1284,26 @@ namespace Vanguard.UI
UiKit.SetPreferredHeight(button, 46f); UiKit.SetPreferredHeight(button, 46f);
} }
private void AddGuardButton(IReadOnlyList<GuardIntent> guards) private void AddRideButtons(IReadOnlyList<RideIntent> 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<GuardIntent> guards)
{ if (guards == null || guards.Count == 0)
return; return;
var button = UiKit.CreateButton( var button = UiKit.CreateButton(
@ -1327,19 +1381,44 @@ namespace Vanguard.UI
if (distinct.Count == 0) if (distinct.Count == 0)
return; 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; return;
} }
var items = distinct var items = new List<CardActionMenuItem>();
.Select(variant => new CardActionMenuItem( if (free != null)
string.IsNullOrEmpty(variant.DiscardPrintingId) items.Add(new CardActionMenuItem("Без сброса", () => OnIntentClicked(free)));
? "Без сброса"
: "Сбросить: " + CardName(variant.DiscardPrintingId), var hand = PlayerHand();
() => OnIntentClicked(variant))) foreach (var card in hand
.ToList(); .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); ShowChoiceMenu("Сброс для способности", items);
} }
@ -1354,29 +1433,48 @@ namespace Vanguard.UI
if (distinct.Count == 0) if (distinct.Count == 0)
return; return;
if (distinct.Count > 1) var discardVariants = distinct
{
distinct = distinct
.Where(guard => !string.IsNullOrEmpty(guard.DiscardPrintingId)) .Where(guard => !string.IsNullOrEmpty(guard.DiscardPrintingId))
.ToList(); .ToList();
} if (discardVariants.Count == 0)
if (distinct.Count == 1)
{ {
OnIntentClicked(distinct[0]); OnIntentClicked(distinct[0]);
return; return;
} }
var items = distinct var items = new List<CardActionMenuItem>();
.Select(guard => new CardActionMenuItem( var hand = PlayerHand();
string.IsNullOrEmpty(guard.DiscardPrintingId) foreach (var card in hand
? "Guard без сброса" .GroupBy(item => item.PrintingId)
: "Perfect Guard: сбросить " + CardName(guard.DiscardPrintingId), .Select(group => group.First()))
() => OnIntentClicked(guard))) {
.ToList(); 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); ShowChoiceMenu("Guard", items, onClosed);
} }
private List<CardView> PlayerHand()
{
var player = _view?.Players.FirstOrDefault(item => item.Index == _view.You);
return player?.Hand ?? new List<CardView>();
}
private void ShowChoiceMenu( private void ShowChoiceMenu(
string title, string title,
IReadOnlyList<CardActionMenuItem> items, IReadOnlyList<CardActionMenuItem> items,
@ -2204,10 +2302,12 @@ namespace Vanguard.UI
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
var abilities = playable.OfType<ActivateAbilityIntent>().ToList(); var abilities = playable.OfType<ActivateAbilityIntent>().ToList();
var guards = playable.OfType<GuardIntent>().ToList(); var guards = playable.OfType<GuardIntent>().ToList();
var rides = playable.OfType<RideIntent>().ToList();
var board = playable var board = playable
.Where(intent => IsBoardIntent(intent) .Where(intent => IsBoardIntent(intent)
&& intent is not ActivateAbilityIntent && intent is not ActivateAbilityIntent
&& intent is not GuardIntent) && intent is not GuardIntent
&& intent is not RideIntent)
.ToList(); .ToList();
var global = playable var global = playable
.Where(intent => !IsBoardIntent(intent) .Where(intent => !IsBoardIntent(intent)
@ -2274,11 +2374,27 @@ namespace Vanguard.UI
var exchange = UiKit.CreateButton( var exchange = UiKit.CreateButton(
_intents, _intents,
"MulliganExchange", "MulliganExchange",
"Маллиган: " + _mulliganSelected.Count + " карт", "Заменить " + _mulliganSelected.Count + " карт",
SendMulligan, SendMulligan,
16); 16);
UiKit.SetPreferredHeight(exchange, 44f); UiKit.SetPreferredHeight(exchange, 44f);
} }
else
{
var finishMulligan = (view.LegalIntents ?? new List<IntentPayload>())
.OfType<FinishMulliganIntent>()
.FirstOrDefault();
if (finishMulligan != null)
{
var finishButton = UiKit.CreateButton(
_intents,
"MulliganFinish",
"Закончить муллиган",
() => OnIntentClicked(finishMulligan),
16);
UiKit.SetPreferredHeight(finishButton, 44f);
}
}
} }
if (IsAbilityDamageTargeting()) if (IsAbilityDamageTargeting())
@ -2325,6 +2441,9 @@ namespace Vanguard.UI
if (guards.Count > 0) if (guards.Count > 0)
AddGuardButton(guards); AddGuardButton(guards);
if (rides.Count > 0)
AddRideButtons(rides);
foreach (var intent in global) foreach (var intent in global)
AddIntentButton(intent); AddIntentButton(intent);

View File

@ -74,6 +74,11 @@ namespace Vanguard.UI
["Защититься: "] = "Guard: ", ["Защититься: "] = "Guard: ",
["Защита"] = "Guard", ["Защита"] = "Guard",
["Не защищаться"] = "Do not guard", ["Не защищаться"] = "Do not guard",
["Скрыть панель"] = "Hide panel",
["Показать панель"] = "Show panel",
["Сменить: "] = "Ride: ",
["Заменить "] = "Replace ",
["Закончить муллиган"] = "Finish mulligan",
["Действия"] = "Actions", ["Действия"] = "Actions",
["Переместить"] = "Move", ["Переместить"] = "Move",
["Подробнее"] = "Details", ["Подробнее"] = "Details",

View File

@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат | | Проверка | Результат |
|---|---| |---|---|
| Тесты сервера | **186/186** (141 правил/контрактов + 45 сервер) | | Тесты сервера | **187/187** (142 правил/контрактов + 45 сервер) |
| `validate` | 0 ошибок, 0 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `stress 1000` | 1000/1000 партий, 0 ошибок |
@ -987,17 +987,22 @@ Battle; Boot и общие сервисы остаются персистент
Атака по-прежнему запрещена. Атака по-прежнему запрещена.
- Смена: в фазе Ride предлагается и карта того же ранга, и следующего - Смена: в фазе Ride предлагается и карта того же ранга, и следующего
(раньше legal-команды содержали только `+1`, хотя движок разрешал same (раньше legal-команды содержали только `+1`, хотя движок разрешал same
grade). grade). В панели действий есть явная кнопка «Сменить: карта».
- Маллиган: кнопка динамическая — без выбора «Закончить муллиган», с
выбором «Заменить X карт».
- Способности: `ActivateAbilityIntent` группируются по карте; в UI одна - Способности: `ActivateAbilityIntent` группируются по карте; в UI одна
кнопка «Активировать способность». Если целей/вариантов несколько — кнопка «Активировать способность». Если целей/вариантов несколько —
отдельный overlay, затем выбор карты для сброса (без duplicate-кнопок). отдельный overlay, затем выбор карты для сброса (без duplicate-кнопок).
- Guard: варианты Perfect Guard/сброса показываются отдельным overlay; - Guard: варианты Perfect Guard/сброса показываются отдельным overlay;
случайный первый интент больше не выбирается. В фазе защиты есть случайный первый интент больше не выбирается. В фазе защиты есть
«Защититься» (при наличии допустимых карт) и «Не защищаться». «Защититься» (при наличии допустимых карт) и «Не защищаться». Сброс
выбирается из карт в руке, а не по первому интенту.
- Правую панель действий можно временно скрыть/показать кнопкой в верхней
строке, чтобы видеть поле.
- Камера боя: `(0, 4.2, -8)` → цель `(0, 0, 0)`, только наклон по X; - Камера боя: `(0, 4.2, -8)` → цель `(0, 0, 0)`, только наклон по X;
поле по центру, без бокового смещения. поле по центру, без бокового смещения.
- UI: reference-разрешение **1920×1080**, CanvasScaler `matchWidthOrHeight - UI: reference-разрешение **1920×1080**, CanvasScaler `matchWidthOrHeight
= 0.5`; standalone принудительно `1920×1080 FullScreenWindow`, = 0.5`; standalone принудительно `1920×1080 FullScreenWindow`,
`defaultIsNativeResolution = 0`. `defaultIsNativeResolution = 0`.
- Проверки: сервер **141** правил + **45** серверных, `validate` и - Проверки: сервер **142** правил + **45** серверных, `validate` и
`translations check` OK; клиент EditMode **30/30**. `translations check` OK; клиент EditMode **30/30**.

View File

@ -19,7 +19,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **141** правил + **45** серверных, CI зелёный. - Тесты: **142** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Документация: `docs/server-api.md`, `docs/protocol.md`, - Документация: `docs/server-api.md`, `docs/protocol.md`,
`docs/game-rules.md`, `docs/HANDOFF.md`. `docs/game-rules.md`, `docs/HANDOFF.md`.
@ -84,12 +84,15 @@
`P/C`. `P/C`.
- Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом - Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом
ходу переход в Battle не предлагается; атака по-прежнему запрещена. ходу переход в Battle не предлагается; атака по-прежнему запрещена.
В панели действий отображается явная кнопка «Сменить: карта».
- Способности: кнопка «Активировать способность» одна на карту; при - Способности: кнопка «Активировать способность» одна на карту; при
нескольких вариантах открывается overlay выбора, затем — выбор карты нескольких вариантах открывается overlay выбора, затем — выбор карты
для сброса. Perfect Guard также спрашивает карту для сброса отдельным для сброса. Perfect Guard также спрашивает карту для сброса отдельным
overlay, а не выбирает случайную. overlay, а не выбирает случайную.
- В фазе защиты доступны «Защититься» (если есть допустимые карты) и - В фазе защиты доступны «Защититься» (если есть допустимые карты) и
«Не защищаться»; выбор карты защиты и сброса идёт через overlay. «Не защищаться»; выбор карты защиты и сброса идёт через overlay.
- Правую панель действий можно временно скрыть кнопкой «Скрыть панель»
в верхней строке; поле остаётся видимым, вернуть — «Показать панель».
- Статистика карт — отдельное UI-наложение: своя панель снизу слева, - Статистика карт — отдельное UI-наложение: своя панель снизу слева,
противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер` противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер`
кликабельны и открывают общий горизонтальный `CardListView`; из списка кликабельны и открывают общий горизонтальный `CardListView`; из списка
@ -105,7 +108,9 @@
выбранная карта переворачивается рубашкой. Heal выбирает карту урона; выбранная карта переворачивается рубашкой. Heal выбирает карту урона;
Counter Blast — открытую карту до активации способности. Counter Blast — открытую карту до активации способности.
- Маллиган: один раз на игрока, оба игрока; выбранные карты уходят в низ - Маллиган: один раз на игрока, оба игрока; выбранные карты уходят в низ
колоды, добираются новые, затем shuffle. Клик по руке выбирает карты. колоды, добираются новые, затем shuffle. Клик по руке выбирает карты;
кнопка динамическая: без выбора — «Закончить муллиган», с выбором —
«Заменить X карт».
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх - Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
стола; ввод заблокирован до завершения через `InputLockService`, очередь стола; ввод заблокирован до завершения через `InputLockService`, очередь
событий + кнопка «Пропустить анимации». Длительности и скорости карт — событий + кнопка «Пропустить анимации». Длительности и скорости карт —

View File

@ -131,6 +131,28 @@ public class GameEngineTests
Assert.True(engine.Execute(new RideCommand(Conroe)).Accepted); 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] [Fact]
public void Both_Players_Mulligan_Once_Then_First_Player_Starts() public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
{ {