Add battle targeting, action groups, and event log
CI / test (push) Successful in 9m52s Details

This commit is contained in:
MpakobecEx 2026-10-21 22:38:02 +05:00
parent 0e528586b4
commit 0e4737ac22
5 changed files with 423 additions and 16 deletions

View File

@ -276,7 +276,8 @@
- [x] Процедурные карты: цвет/градиент клана, рубашка, имя, ранг, триггер,
сила/крит; REST/скрытая карта.
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»).
- [x] Ввод: кнопки легальных интентов; подсветка целей — дальше.
- [x] Ввод: кнопки легальных интентов; клик по картам/зонам,
подсветка целей, группировка действий и журнал боя.
- [x] Отображение силы/крита из `MatchView`; модификаторы уже посчитаны сервером.
- [ ] Очередь анимаций строго по событиям сервера: ride, call, attack, drive, damage, retire, boost, guard.
- [ ] Скип/ускорение анимаций.

View File

@ -41,7 +41,9 @@ namespace Vanguard.UI
private readonly Text _concedeLabel;
private readonly Button _reconnect;
private readonly RectTransform _result;
private RectTransform _logContent;
private Text _resultText;
private readonly List<string> _logLines = new List<string>();
private GameConnection _connection;
private CancellationTokenSource _cts;
@ -52,9 +54,12 @@ namespace Vanguard.UI
private bool _snapshotPending;
private bool _finished;
private bool _confirmConcede;
private bool _showAllIntents;
private float _confirmDeadline;
private long _clockServerMs;
private long _clockReceivedMs;
private readonly List<IntentPayload> _pendingIntents = new List<IntentPayload>();
private readonly HashSet<string> _highlightKeys = new HashSet<string>(StringComparer.Ordinal);
public BattleView(Transform parent, MetaService meta, Action onExit)
{
@ -116,7 +121,17 @@ namespace Vanguard.UI
_status = UiKit.CreateText(
right, "Status", string.Empty, 16, Muted, TextAnchor.UpperLeft);
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(_status, 110f);
UiKit.SetPreferredHeight(_status, 74f);
var logTitle = UiKit.CreateText(
right, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
logTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(logTitle, 22f);
var logScroll = UiKit.CreateScrollView(right, "LogScroll", out _logContent, 3f);
var logElement = logScroll.gameObject.AddComponent<LayoutElement>();
logElement.preferredHeight = 142f;
logElement.minHeight = 80f;
_reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18);
UiKit.SetPreferredHeight(_reconnect, 46f);
@ -138,6 +153,11 @@ namespace Vanguard.UI
_concede.interactable = true;
_reconnect.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_pendingIntents.Clear();
_highlightKeys.Clear();
_showAllIntents = false;
_logLines.Clear();
RebuildLog();
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
@ -286,6 +306,8 @@ namespace Vanguard.UI
_view = snapshot.View;
_finished = _view.IsFinished;
_pendingIntents.Clear();
_highlightKeys.Clear();
if (_view.Clock != null)
{
_clockServerMs = _view.Clock.ServerTimeMs;
@ -297,9 +319,78 @@ namespace Vanguard.UI
private void OnEvents(ServerEvents events)
{
if (events?.Events != null)
{
foreach (var envelope in events.Events)
{
var line = Describe(envelope.Event);
if (!string.IsNullOrWhiteSpace(line))
_logLines.Add(line);
}
while (_logLines.Count > 40)
_logLines.RemoveAt(0);
RebuildLog();
}
_ = RequestSnapshotAsync();
}
private void RebuildLog()
{
if (_logContent == null)
return;
ClearChildren(_logContent);
var start = Mathf.Max(0, _logLines.Count - 8);
for (var i = start; i < _logLines.Count; i++)
{
var line = UiKit.CreateText(
_logContent, "LogLine", _logLines[i], 13,
i == _logLines.Count - 1
? new Color(0.92f, 0.94f, 1f)
: new Color(0.60f, 0.68f, 0.82f),
TextAnchor.MiddleLeft);
line.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(line, 30f);
}
}
private string Describe(EventView view)
{
if (view == null)
return null;
return view switch
{
UnitRodeView ride => "Ride: " + CardName(ride.PrintingId),
UnitCalledView call => "Call: " + CardName(call.PrintingId) + " → " + SlotName(call.Slot),
UnitMovedView move => "Move: " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot),
AttackDeclaredView attack => "Атака: " + SlotName(attack.AttackerSlot)
+ " → " + SlotName(attack.TargetSlot),
GuardAddedView guard => "Guard: " + CardName(guard.PrintingId)
+ " (+" + guard.Shield + ")",
PerfectGuardView perfect => "Perfect Guard: " + CardName(perfect.SentinelPrintingId),
DriveCheckedView drive => "Drive: " + CardName(drive.PrintingId)
+ " · " + drive.Trigger,
TriggerResolvedView trigger => "Триггер: " + CardName(trigger.PrintingId),
BattleResolvedView battle => battle.Hit ? "Атака прошла." : "Атака отбита.",
DamageTakenView damage => "Урон: " + PlayerName(damage.Player)
+ " +" + damage.Amount + " (всего " + damage.Total + ")",
HealedView heal => "Heal: " + PlayerName(heal.Player),
UnitRetiredView retire => "Retire: " + SlotName(retire.Slot),
InterceptedView intercept => "Intercept: " + CardName(intercept.PrintingId),
CardDrawnView draw => "Карта: " + PlayerName(draw.Player),
PhaseChangedView phase => "Фаза: " + phase.Phase,
GameFinishedView finish => "Партия завершена.",
_ => null,
};
}
private string PlayerName(int player) =>
_view == null ? "P" + player : player == _view.You ? "Вы" : "Противник";
private void OnClock(ServerClock clock)
{
if (!_root.gameObject.activeSelf || clock?.Clock == null)
@ -358,6 +449,8 @@ namespace Vanguard.UI
if (_sending || _connection == null || _cts == null || _view == null)
return;
_pendingIntents.Clear();
_highlightKeys.Clear();
_sending = true;
SetStatus("Отправляем: " + Label(intent), Muted);
@ -504,13 +597,13 @@ namespace Vanguard.UI
rowElement.minHeight = 166f;
UiKit.AddHorizontalLayout(row, 3, 7);
AddCardBox(row, player.Vanguard, "V", compact: false);
AddCardBox(row, player.Vanguard, "V", compact: false, player.Index, -1);
for (var i = 0; i < 5; i++)
{
var card = player.RearGuards != null && i < player.RearGuards.Length
? player.RearGuards[i]
: null;
AddCardBox(row, card, "R" + (i + 1), compact: false);
AddCardBox(row, card, "R" + (i + 1), compact: false, player.Index, i);
}
}
@ -588,7 +681,7 @@ namespace Vanguard.UI
}
foreach (var card in self.Hand)
AddCardBox(content, card, "Рука", compact: true);
AddCardBox(content, card, "Рука", compact: true, self.Index, -2);
}
private void AddInfoLine(Transform parent, string text)
@ -598,7 +691,13 @@ namespace Vanguard.UI
UiKit.SetPreferredHeight(line, 22f);
}
private void AddCardBox(Transform parent, CardView card, string slot, bool compact)
private void AddCardBox(
Transform parent,
CardView card,
string slot,
bool compact,
int playerIndex,
int slotIndex)
{
var meta = card != null && _catalog.TryGetValue(card.PrintingId, out var found)
? found
@ -619,9 +718,19 @@ namespace Vanguard.UI
element.preferredHeight = compact ? 142f : 162f;
element.flexibleWidth = 0f;
var highlighted = _highlightKeys.Contains(
TargetKey(playerIndex, slotIndex, card != null ? card.PrintingId : null));
var button = panel.gameObject.AddComponent<Button>();
button.targetGraphic = panel.GetComponent<Image>();
button.onClick.AddListener(() => OnBoardTargetClicked(new BoardTarget(
playerIndex, slotIndex, card != null ? card.PrintingId : null,
card != null ? card.InstanceId : 0, slotIndex == -2)));
var outline = panel.gameObject.AddComponent<Outline>();
outline.effectColor = frameColor;
outline.effectDistance = new Vector2(2f, -2f);
outline.effectColor = highlighted ? new Color(1f, 0.82f, 0.28f) : frameColor;
outline.effectDistance = highlighted
? new Vector2(3f, -3f)
: new Vector2(2f, -2f);
UiKit.AddVerticalLayout(panel, 4, 2);
var slotText = UiKit.CreateText(
@ -734,12 +843,233 @@ namespace Vanguard.UI
private string CardName(string printingId) =>
_catalog.TryGetValue(printingId, out var meta) ? meta.NameEn : printingId;
private void OnBoardTargetClicked(BoardTarget target)
{
if (_view == null || _sending)
return;
var legal = _view.LegalIntents ?? new List<IntentPayload>();
if (_pendingIntents.Count > 0)
{
var matches = _pendingIntents
.Where(intent => TryGetTarget(intent, out var candidate) && candidate.Matches(target))
.OrderBy(intent => intent is AttackIntent attack && attack.BoosterSlot >= 0 ? 1 : 0)
.ToList();
if (matches.Count > 0)
{
ClearSelection(rebuild: false);
OnIntentClicked(matches[0]);
return;
}
}
var sources = legal
.Where(intent => TryGetSource(intent, out var source) && source.Matches(target))
.ToList();
if (sources.Count == 0)
{
SetStatus("Для этой карты сейчас нет доступного действия.", Muted);
return;
}
if (sources.Count == 1)
{
ClearSelection(rebuild: false);
OnIntentClicked(sources[0]);
return;
}
var withTargets = sources
.Where(intent => TryGetTarget(intent, out _))
.ToList();
if (withTargets.Count == 0)
{
ClearSelection(rebuild: false);
OnIntentClicked(sources[0]);
return;
}
_pendingIntents.Clear();
_pendingIntents.AddRange(withTargets);
RebuildHighlights();
RebuildIntents(_view);
SetStatus("Выберите цель на поле.", new Color(0.98f, 0.82f, 0.42f));
}
private void ClearSelection(bool rebuild = true)
{
_pendingIntents.Clear();
_highlightKeys.Clear();
if (rebuild && _view != null)
RebuildIntents(_view);
}
private void RebuildHighlights()
{
_highlightKeys.Clear();
foreach (var intent in _pendingIntents)
{
if (TryGetTarget(intent, out var target))
_highlightKeys.Add(TargetKey(target.Player, target.Slot, target.PrintingId));
}
}
private bool TryGetSource(IntentPayload intent, out BoardTarget source)
{
source = default;
if (_view == null)
return false;
switch (intent)
{
case RideIntent ride:
source = new BoardTarget(_view.You, -2, ride.PrintingId, 0, true);
return true;
case CallIntent call:
source = new BoardTarget(_view.You, -2, call.PrintingId, 0, true);
return true;
case GuardIntent guard:
source = new BoardTarget(_view.You, -2, guard.PrintingId, 0, true);
return true;
case MoveIntent move:
source = FieldTarget(_view.You, move.FromSlot);
return true;
case AttackIntent attack:
source = FieldTarget(_view.You, attack.AttackerSlot);
return true;
case InterceptIntent intercept:
source = FieldTarget(_view.You, intercept.Slot);
return true;
case ActivateAbilityIntent activate:
source = InstanceTarget(_view.You, activate.InstanceId);
return source.InstanceId != 0;
default:
return false;
}
}
private bool TryGetTarget(IntentPayload intent, out BoardTarget target)
{
target = default;
if (_view == null)
return false;
switch (intent)
{
case CallIntent call:
target = FieldTarget(_view.You, call.Slot);
return true;
case MoveIntent move:
target = FieldTarget(_view.You, move.ToSlot);
return true;
case AttackIntent attack:
target = FieldTarget(1 - _view.You, attack.TargetSlot);
return true;
default:
return false;
}
}
private BoardTarget FieldTarget(int player, int slot)
{
var card = CardAt(player, slot);
return new BoardTarget(player, slot, card != null ? card.PrintingId : null,
card != null ? card.InstanceId : 0, false);
}
private BoardTarget InstanceTarget(int player, int instanceId)
{
var card = InstanceAt(player, instanceId);
if (card == null)
return default;
if (_view != null
&& _view.Players.FirstOrDefault(item => item.Index == player)?.Vanguard is { } vanguard
&& vanguard.InstanceId == instanceId)
{
return new BoardTarget(player, -1, card.PrintingId, instanceId, false);
}
var field = _view?.Players.FirstOrDefault(item => item.Index == player);
if (field?.RearGuards != null)
{
for (var i = 0; i < field.RearGuards.Length; i++)
{
if (field.RearGuards[i]?.InstanceId == instanceId)
return new BoardTarget(player, i, card.PrintingId, instanceId, false);
}
}
return default;
}
private CardView CardAt(int player, int slot)
{
var side = _view?.Players.FirstOrDefault(item => item.Index == player);
if (side == null)
return null;
if (slot == -1)
return side.Vanguard;
return side.RearGuards != null && slot >= 0 && slot < side.RearGuards.Length
? side.RearGuards[slot]
: null;
}
private CardView InstanceAt(int player, int instanceId)
{
var side = _view?.Players.FirstOrDefault(item => item.Index == player);
if (side == null)
return null;
if (side.Vanguard?.InstanceId == instanceId)
return side.Vanguard;
return side.RearGuards?.FirstOrDefault(card => card?.InstanceId == instanceId);
}
private static string TargetKey(int player, int slot, string printingId) =>
player + ":" + slot + ":" + (printingId ?? string.Empty);
private static bool IsBoardIntent(IntentPayload intent) =>
intent is RideIntent or CallIntent or MoveIntent or AttackIntent
or GuardIntent or InterceptIntent or ActivateAbilityIntent;
private void RebuildIntents(MatchView view)
{
ClearChildren(_intents);
var legal = view.LegalIntents ?? new List<IntentPayload>();
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
var board = playable.Where(IsBoardIntent).ToList();
var global = playable.Where(intent => !IsBoardIntent(intent)).ToList();
if (_pendingIntents.Count > 0)
{
var prompt = UiKit.CreateText(
_intents,
"TargetPrompt",
"Выберите цель на поле.\nНажмите «Отмена», чтобы снять выбор.",
16,
new Color(0.98f, 0.82f, 0.42f),
TextAnchor.UpperLeft);
prompt.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(prompt, 64f);
var cancel = UiKit.CreateButton(
_intents, "CancelTarget", "Отмена", () =>
{
ClearSelection();
Render();
}, 17);
UiKit.SetPreferredHeight(cancel, 44f);
return;
}
if (playable.Count == 0)
{
@ -753,7 +1083,42 @@ namespace Vanguard.UI
return;
}
foreach (var intent in playable)
if (board.Count > 0)
{
var hint = UiKit.CreateText(
_intents,
"BoardHint",
"Нажмите на карту в руке или на поле.\nДопустимые цели подсветятся.",
15,
new Color(0.72f, 0.80f, 0.94f),
TextAnchor.UpperLeft);
hint.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(hint, 52f);
var toggle = UiKit.CreateButton(
_intents,
"ToggleAllIntents",
_showAllIntents ? "Скрыть все действия" : "Показать все действия",
() =>
{
_showAllIntents = !_showAllIntents;
RebuildIntents(_view);
},
15);
UiKit.SetPreferredHeight(toggle, 38f);
}
foreach (var intent in global)
AddIntentButton(intent);
if (_showAllIntents)
{
foreach (var intent in board)
AddIntentButton(intent);
}
}
private void AddIntentButton(IntentPayload intent)
{
var captured = intent;
var button = UiKit.CreateButton(
@ -761,9 +1126,8 @@ namespace Vanguard.UI
"Intent_" + captured.GetType().Name,
Label(captured),
() => OnIntentClicked(captured),
17);
UiKit.SetPreferredHeight(button, 50f);
}
16);
UiKit.SetPreferredHeight(button, 46f);
}
private void ShowResult(MatchView view)
@ -879,5 +1243,40 @@ namespace Vanguard.UI
_status.text = text ?? string.Empty;
_status.color = color;
}
private readonly struct BoardTarget
{
public BoardTarget(
int player,
int slot,
string printingId,
int instanceId,
bool isHand)
{
Player = player;
Slot = slot;
PrintingId = printingId;
InstanceId = instanceId;
IsHand = isHand;
}
public int Player { get; }
public int Slot { get; }
public string PrintingId { get; }
public int InstanceId { get; }
public bool IsHand { get; }
public bool Matches(BoardTarget other)
{
if (IsHand || other.IsHand)
{
return IsHand && other.IsHand
&& Player == other.Player
&& string.Equals(PrintingId, other.PrintingId, StringComparison.Ordinal);
}
return Player == other.Player && Slot == other.Slot;
}
}
}
}

View File

@ -64,6 +64,8 @@ http://localhost:5080
интенты, ответы на ping, закрытие и переподключение.
- Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов,
сдача с подтверждением и экран результата.
- Боевой UX: клик по картам/зонам, подсветка целей, сгруппированные
действия, журнал событий и подсказки.
- Карты в бою и коллекции: процедурные арты по клану, рамка, имя, ранг,
триггер, сила/крит, REST и рубашка для скрытых карт.
- Экран входа и главный экран с балансами, MMR и переходами в разделы.

View File

@ -745,5 +745,7 @@ Battle; Boot и общие сервисы остаются персистент
рубашку; `BattleView` показывает рамки, имена, ранги, триггеры,
силу/крит, REST и скрытые карты; поля обоих игроков, рука и панель
действий. Коллекция использует те же арты.
- Боевой UX: клик по карте/зоне, подсветка целей, отдельная группировка
действий, журнал последних событий и подсказки выбора.
- Проверки визуального блока: Unity EditMode 2/2, `SCENE_SMOKE success=True`,
live-практика с ботом на стенде.

View File

@ -58,6 +58,9 @@
- Боевой экран: процедурные карты с арт-градиентом по клану, рамкой,
именем, рангом, триггером, силой/критом и рубашкой; поля Vanguard +
5 Арьергардов у обоих, рука, damage/soul/drop, REST/скрытые карты.
- Взаимодействие в бою: клик по картам/зонам, подсветка целей,
группировка действий (карты → цели, глобальные кнопки отдельно),
журнал последних событий.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: реальные арты/модели/анимации, локализация RU/EN, настройки,