Add shared card list overlays, zone blocks and opponent hand backs
CI / test (push) Successful in 2m16s Details

This commit is contained in:
MpakobecEx 2026-10-23 14:51:19 +05:00
parent 1ee8e7fb31
commit 309b56e4d7
24 changed files with 1007 additions and 239 deletions

View File

@ -62,7 +62,8 @@ namespace Vanguard.Editor
foreach (var card in cards)
{
var viewport = camera.WorldToViewportPoint(card.transform.position);
if (card.Ref.Zone == Vanguard.Board3D.BoardZone.Hand)
if (card.Ref.Zone == Vanguard.Board3D.BoardZone.Hand
&& card.Ref.Player == 0)
{
handCards++;
if (viewport.z > 0f

View File

@ -43,6 +43,13 @@ MonoBehaviour:
handViewScale: 0.5
handViewRoll: 3.2
handViewArc: 0.02
opponentHandEnabled: 1
opponentHandZ: 4.3
opponentHandHeight: 0.75
opponentHandSpacing: 0.42
opponentHandMaxWidth: 2.4
opponentHandScale: 0.8
opponentHandRoll: 2.5
guardianRowGap: 0.55
guardianSpacing: 0.44
guardianMaxWidth: 3.6

View File

@ -61,6 +61,15 @@ namespace Vanguard.Board3D
public float handViewRoll = 3.2f;
public float handViewArc = 0.02f;
[Header("Рука противника")]
public bool opponentHandEnabled = true;
public float opponentHandZ = 4.3f;
public float opponentHandHeight = 0.75f;
public float opponentHandSpacing = 0.42f;
public float opponentHandMaxWidth = 2.4f;
public float opponentHandScale = 0.8f;
public float opponentHandRoll = 2.5f;
[Header("Круг защиты")]
public float guardianRowGap = 0.55f;
public float guardianSpacing = 0.44f;
@ -144,6 +153,12 @@ namespace Vanguard.Board3D
handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f);
handViewRoll = Mathf.Clamp(handViewRoll, 0f, 20f);
handViewArc = Mathf.Max(0f, handViewArc);
opponentHandZ = Mathf.Max(1f, opponentHandZ);
opponentHandHeight = Mathf.Max(0.2f, opponentHandHeight);
opponentHandSpacing = Mathf.Max(0.1f, opponentHandSpacing);
opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth);
opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f);
opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f);
}
}
}

View File

@ -133,6 +133,27 @@ namespace Vanguard.Board3D
return new HandPose(new Vector3(x, y, z), yaw);
}
/// <summary>Веер рубашек противника у дальнего края стола.</summary>
public HandPose GetOpponentHandPose(int index, int count)
{
var safeCount = Mathf.Max(1, count);
var spacing = _config.opponentHandSpacing;
if (safeCount > 1)
{
spacing = Mathf.Min(
spacing,
_config.opponentHandMaxWidth / (safeCount - 1));
}
var offset = index - (safeCount - 1) * 0.5f;
var x = offset * spacing;
var z = _config.opponentHandZ + Mathf.Abs(offset) * 0.015f;
var y = _config.opponentHandHeight
- (1f - Mathf.Abs(offset) / safeCount) * 0.02f;
var roll = -offset * _config.opponentHandRoll;
return new HandPose(new Vector3(x, y, z), roll);
}
/// <summary>
/// Ближайшая цель для броска карты. Возвращает false, если карта
/// отпущена мимо кругов и общего круга защиты.

View File

@ -174,6 +174,7 @@ namespace Vanguard.Board3D
Vanguard = Card("BT01/020EN", 101),
RearGuards = new CardView[5],
Hand = new List<CardView>(),
HandCount = 3,
Guardian = new List<CardView>(),
Soul = new List<CardView> { Card("BT01/021EN", 131) },
FaceDownDamage = 1,

View File

@ -468,6 +468,7 @@ namespace Vanguard.Board3D
}
AddHandSpecs(view, state, specs);
AddOpponentHandSpecs(view, specs);
}
private void AddFieldSpecs(
@ -758,6 +759,33 @@ namespace Vanguard.Board3D
}
}
private void AddOpponentHandSpecs(MatchView view, List<CardSpec> specs)
{
if (!_config.opponentHandEnabled)
return;
var opponent = view.Players.FirstOrDefault(player => player.Index != view.You);
if (opponent == null || opponent.HandCount <= 0)
return;
var count = opponent.HandCount;
for (var i = 0; i < count; i++)
{
var pose = _layout.GetOpponentHandPose(i, count);
specs.Add(new CardSpec
{
Key = "OH:" + opponent.Index + ":" + i,
Ref = BoardCardRef.Hand(opponent.Index, i, null),
Art = BoardArt.Fallback(_config.matOpponentColor),
Position = pose.Position,
BaseRotation = Quaternion.Euler(-90f, 0f, pose.Yaw),
FaceUp = false,
Rested = false,
Scale = _config.opponentHandScale,
});
}
}
private void UpdatePiles(MatchView view)
{
var self = view.Players.FirstOrDefault(player => player.Index == view.You);
@ -959,6 +987,9 @@ namespace Vanguard.Board3D
if (_pressedCard == null || _pressedCardRef.IsEmpty)
return;
if (_pressedCardRef.Player != _selfPlayer)
return;
if (!_pressedCardRef.IsField && !_pressedCardRef.IsHand)
return;

View File

@ -11,6 +11,7 @@ using Vanguard.Battle;
using Vanguard.Board3D;
using Vanguard.Meta;
using Vanguard.Shared.Protocol;
using Vanguard.UI.Overlays;
namespace Vanguard.UI
{
@ -75,11 +76,9 @@ namespace Vanguard.UI
private readonly HashSet<BoardSlotKey> _highlightSlots = new HashSet<BoardSlotKey>();
private GameBoard3D _board3D;
private RectTransform _soulOverlay;
private RectTransform _soulContent;
private Text _soulTitle;
private Text _selfStatsText;
private Text _opponentStatsText;
private OverlayNavigationService _overlays;
private ZoneBlockStripView _selfZones;
private ZoneBlockStripView _opponentZones;
private RectTransform _phasePanel;
private bool _autoFinishMulligan;
private float _autoFinishMulliganAt;
@ -186,8 +185,7 @@ namespace Vanguard.UI
feedbackRect.anchoredPosition = new Vector2(0f, 130f);
_feedback.gameObject.SetActive(false);
BuildSoulOverlay();
BuildStatsOverlays();
BuildOverlaysAndZones();
BuildPhasePanel();
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
@ -235,13 +233,10 @@ namespace Vanguard.UI
_skipFeedback.gameObject.SetActive(false);
_board3D?.Clear();
_board3D?.SetVisible(true);
if (_soulOverlay != null)
_soulOverlay.gameObject.SetActive(false);
_overlays?.CloseAll();
_selfZones?.Update(null, self: true);
_opponentZones?.Update(null, self: false);
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
if (_selfStatsText != null)
_selfStatsText.text = string.Empty;
if (_opponentStatsText != null)
_opponentStatsText.text = string.Empty;
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
_ = LoadCatalogAsync();
@ -266,8 +261,7 @@ namespace Vanguard.UI
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
if (_soulOverlay != null)
_soulOverlay.gameObject.SetActive(false);
_overlays?.CloseAll();
_board3D?.SetVisible(false);
_view = null;
}
@ -858,7 +852,7 @@ namespace Vanguard.UI
_board3D?.SetSelfPlayer(_view.You);
_board3D?.Render(_view, BuildBoardRenderState());
UpdateStatsOverlay();
UpdateZoneStrips();
RebuildIntents(_view);
RebuildPhaseControls(_view);
@ -977,6 +971,12 @@ namespace Vanguard.UI
switch (card.Zone)
{
case BoardZone.Hand:
if (card.Player != _view.You)
{
OpenZone(BoardZone.Hand, card.Player);
break;
}
OnBoardTargetClicked(new BoardTarget(
card.Player, -2, card.PrintingId, card.InstanceId, true));
break;
@ -1083,7 +1083,7 @@ namespace Vanguard.UI
return;
if (pile.Zone == BoardZone.Soul)
ShowSoul(pile.Player);
OpenZone(BoardZone.Soul, pile.Player);
}
private void OnBoard3DCardRightClicked(BoardCardRef card)
@ -1092,7 +1092,7 @@ namespace Vanguard.UI
return;
if (card.Zone == BoardZone.Vanguard)
ShowSoul(card.Player);
OpenZone(BoardZone.Soul, card.Player);
}
private void OnBoard3DPileClicked(BoardCardRef pile)
@ -1102,22 +1102,18 @@ namespace Vanguard.UI
if (pile.Zone == BoardZone.Soul)
{
SetStatus("Душа открывается правой кнопкой мыши.", Muted);
SetStatus("Душа открывается правой кнопкой мыши по VC.", Muted);
return;
}
if (pile.Zone == BoardZone.Drop)
{
ShowDrop(pile.Player);
OpenZone(BoardZone.Drop, pile.Player);
return;
}
var player = _view.Players.FirstOrDefault(item => item.Index == pile.Player);
if (player == null)
return;
if (pile.Zone == BoardZone.Deck)
SetStatus("В колоде " + player.DeckCount + " карт.", Muted);
OpenZone(BoardZone.Deck, pile.Player);
}
private void BuildPhasePanel()
@ -1192,215 +1188,57 @@ namespace Vanguard.UI
_ => intent.GetType().Name,
};
private void BuildStatsOverlays()
{ _selfStatsText = CreateStatsPanel(
"SelfStats",
private void BuildOverlaysAndZones()
{
_overlays = new OverlayNavigationService(_root, ResolveCatalogItem);
_selfZones = new ZoneBlockStripView();
_selfZones.Build(
_root,
"ZoneBlocksSelf",
new Vector2(0f, 0f),
new Vector2(0f, 0f),
new Vector2(30f, 24f),
TextAnchor.MiddleLeft);
_opponentStatsText = CreateStatsPanel(
"OpponentStats",
new Vector2(360f, 108f));
_selfZones.ZoneClicked += zone => OpenZone(zone, _view?.You ?? 0);
_opponentZones = new ZoneBlockStripView();
_opponentZones.Build(
_root,
"ZoneBlocksOpponent",
new Vector2(1f, 1f),
new Vector2(1f, 1f),
new Vector2(-488f, -108f),
TextAnchor.MiddleRight);
new Vector2(360f, 108f));
_opponentZones.ZoneClicked += zone =>
OpenZone(zone, _view != null ? 1 - _view.You : 1);
}
private Text CreateStatsPanel(
string name,
Vector2 anchor,
Vector2 pivot,
Vector2 position,
TextAnchor alignment)
private void UpdateZoneStrips()
{
var panel = UiKit.CreatePanel(
_root,
name,
new Color(0.02f, 0.035f, 0.055f, 0.72f));
panel.anchorMin = anchor;
panel.anchorMax = anchor;
panel.pivot = pivot;
panel.anchoredPosition = position;
panel.sizeDelta = new Vector2(360f, 58f);
var image = panel.GetComponent<Image>();
if (image != null)
image.raycastTarget = false;
var text = UiKit.CreateText(
panel,
"Stats",
string.Empty,
14,
new Color(0.86f, 0.91f, 1f),
alignment);
UiKit.Stretch(text.rectTransform);
text.rectTransform.offsetMin = new Vector2(12f, 5f);
text.rectTransform.offsetMax = new Vector2(-12f, -5f);
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Overflow;
UiKit.MakeLiteral(text);
return text;
}
private void UpdateStatsOverlay()
{
if (_view == null || _selfStatsText == null || _opponentStatsText == null)
if (_view == null)
return;
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
var opponent = _view.Players.FirstOrDefault(player => player.Index != _view.You);
_selfStatsText.text = FormatStats(self, selfSide: true);
_opponentStatsText.text = FormatStats(opponent, selfSide: false);
_selfZones?.Update(self, self: true);
_opponentZones?.Update(opponent, self: false);
}
private static string FormatStats(PlayerView player, bool selfSide)
private void OpenZone(BoardZone zone, int playerIndex)
{
var title = Loc.Translate(selfSide ? "ВЫ" : "ПРОТИВНИК");
if (player == null)
return title;
if (_view == null)
return;
var damage = player.FaceDownDamage + (player.FaceUpDamage?.Count ?? 0);
var hand = Mathf.Max(player.HandCount, player.Hand?.Count ?? 0);
return title + " · " + player.Name + "\n"
+ Loc.Translate("Рука") + " " + hand
+ " " + Loc.Translate("Колода") + " " + player.DeckCount
+ " " + Loc.Translate("ДРОП") + " " + (player.Drop?.Count ?? 0)
+ " " + Loc.Translate("УРОН") + " " + damage
+ " " + Loc.Translate("Душа") + " " + (player.Soul?.Count ?? 0);
}
private void BuildSoulOverlay()
{
_soulOverlay = UiKit.CreatePanel(_root, "SoulOverlay", new Color(0f, 0f, 0f, 0.74f));
var panel = UiKit.CreateCenteredCard(
_soulOverlay,
"SoulPanel",
new Vector2(880f, 640f),
Panel);
UiKit.AddVerticalLayout(panel, 18, 10);
_soulTitle = UiKit.CreateText(
panel,
"SoulTitle",
"Душа",
24,
Color.white,
TextAnchor.MiddleLeft);
_soulTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(_soulTitle, 34f);
var scroll = UiKit.CreateScrollView(panel, "SoulScroll", out _soulContent, 6f);
var scrollElement = scroll.gameObject.AddComponent<LayoutElement>();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 420f;
var close = UiKit.CreateButton(
panel,
"SoulClose",
"Закрыть",
() => _soulOverlay.gameObject.SetActive(false),
18);
UiKit.SetPreferredHeight(close, 46f);
_soulOverlay.gameObject.SetActive(false);
}
private void ShowSoul(int playerIndex)
{
var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex);
var player = _view.Players.FirstOrDefault(item => item.Index == playerIndex);
if (player == null)
return;
ShowCardList("Душа", player.Name, player.Soul);
}
private void ShowDrop(int playerIndex)
{
var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex);
if (player == null)
return;
ShowCardList("Дроп", player.Name, player.Drop);
}
private void ShowCardList(string title, string playerName, List<CardView> cards)
{
if (_soulOverlay == null)
return;
_soulTitle.text = title + " · " + playerName + " · " + (cards?.Count ?? 0);
ClearChildren(_soulContent);
if (cards == null || cards.Count == 0)
{
var empty = UiKit.CreateText(
_soulContent,
"CardListEmpty",
"Нет карт.",
17,
Muted);
UiKit.SetPreferredHeight(empty, 80f);
}
else
{
for (var i = cards.Count - 1; i >= 0; i--)
AddSoulRow(cards[i]);
}
_soulOverlay.SetAsLastSibling();
_soulOverlay.gameObject.SetActive(true);
}
private void AddSoulRow(CardView card)
{
var row = UiKit.CreatePanel(
_soulContent,
"Soul_" + card.InstanceId,
new Color(0.08f, 0.10f, 0.15f, 1f));
var rowElement = row.gameObject.AddComponent<LayoutElement>();
rowElement.preferredHeight = 106f;
UiKit.AddHorizontalLayout(row, 6, 10);
var art = UiKit.CreatePanel(row, "Art", Color.white);
var artElement = art.gameObject.AddComponent<LayoutElement>();
artElement.preferredWidth = 64f;
artElement.preferredHeight = 92f;
artElement.flexibleWidth = 0f;
_catalog.TryGetValue(card.PrintingId, out var meta);
CardImageService.Apply(
art.GetComponent<Image>(),
meta != null ? meta.ImageKey : null,
CardArt.ForCard(
meta != null ? meta.NameId : card.PrintingId,
meta != null ? meta.Clan : "None"));
var name = UiKit.CreateText(
row,
"Name",
meta != null ? Loc.CardName(meta) : card.PrintingId,
17,
Color.white,
TextAnchor.MiddleLeft);
UiKit.MakeLiteral(name);
UiKit.SetPreferredHeight(name, 92f);
if (meta != null)
{
var info = UiKit.CreateText(
row,
"Info",
"G" + meta.Grade + " · P " + card.Power + " · C " + card.Critical,
14,
Muted,
TextAnchor.MiddleRight);
UiKit.MakeLiteral(info);
UiKit.SetPreferredHeight(info, 92f, 0f);
}
_overlays?.OpenZone(player, _view.You, zone);
}
private CardCatalogItemDto ResolveCatalogItem(string printingId) =>
_catalog.TryGetValue(printingId, out var item) ? item : null;
private void OnDamageClicked(int displayIndex)
{
if (IsDamageChoice())

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7f1073196b40e8f498b8c27ce6722408
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,137 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using Vanguard.Api.Contracts;
namespace Vanguard.UI.Overlays
{
/// <summary>Крупная карточка с полным текстом. Для скрытых карт показывает рубашку.</summary>
public sealed class CardDetailOverlay
{
private readonly RectTransform _root;
private readonly Image _art;
private readonly Text _name;
private readonly Text _meta;
private readonly Text _stats;
private readonly Text _ability;
private readonly Func<string, CardCatalogItemDto> _catalog;
private Action _onBack;
public CardDetailOverlay(
Transform parent,
Func<string, CardCatalogItemDto> catalog)
{
_catalog = catalog;
_root = UiKit.CreatePanel(parent, "CardDetailOverlay", new Color(0f, 0f, 0f, 0.82f));
var panel = UiKit.CreateCenteredCard(
_root,
"CardDetailPanel",
new Vector2(900f, 600f),
new Color(0.045f, 0.06f, 0.09f, 1f));
UiKit.AddVerticalLayout(panel, 16, 10);
var header = UiKit.CreatePanel(panel, "Header", Color.clear);
UiKit.AddHorizontalLayout(header, 0, 10);
UiKit.SetPreferredHeight(header, 44f);
_name = UiKit.CreateText(header, "Name", "Карта", 24, Color.white, TextAnchor.MiddleLeft);
_name.fontStyle = FontStyle.Bold;
UiKit.MakeLiteral(_name);
UiKit.SetPreferredHeight(_name, 44f);
var back = UiKit.CreateButton(header, "Back", "Назад", () => _onBack?.Invoke(), 16);
var backElement = UiKit.SetPreferredHeight(back, 40f, 0f);
backElement.preferredWidth = 110f;
var close = UiKit.CreateButton(header, "Close", "Закрыть", () => _onBack?.Invoke(), 16);
var closeElement = UiKit.SetPreferredHeight(close, 40f, 0f);
closeElement.preferredWidth = 110f;
var body = UiKit.CreatePanel(panel, "Body", Color.clear);
var bodyElement = body.gameObject.AddComponent<LayoutElement>();
bodyElement.flexibleHeight = 1f;
bodyElement.minHeight = 500f;
UiKit.AddHorizontalLayout(body, 0, 16);
var artPanel = UiKit.CreatePanel(body, "Art", Color.white);
var artElement = artPanel.gameObject.AddComponent<LayoutElement>();
artElement.preferredWidth = 300f;
artElement.preferredHeight = 460f;
artElement.flexibleWidth = 0f;
_art = artPanel.GetComponent<Image>();
_art.raycastTarget = false;
var info = UiKit.CreatePanel(body, "Info", Color.clear);
var infoElement = info.gameObject.AddComponent<LayoutElement>();
infoElement.flexibleWidth = 1f;
infoElement.flexibleHeight = 1f;
UiKit.AddVerticalLayout(info, 0, 6);
_meta = UiKit.CreateText(info, "Meta", string.Empty, 15, new Color(0.72f, 0.80f, 0.94f), TextAnchor.UpperLeft);
_meta.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.MakeLiteral(_meta);
UiKit.SetPreferredHeight(_meta, 52f);
_stats = UiKit.CreateText(info, "Stats", string.Empty, 17, Color.white, TextAnchor.UpperLeft);
_stats.fontStyle = FontStyle.Bold;
UiKit.MakeLiteral(_stats);
UiKit.SetPreferredHeight(_stats, 40f);
_ability = UiKit.CreateText(info, "Ability", string.Empty, 15, Color.white, TextAnchor.UpperLeft);
_ability.horizontalOverflow = HorizontalWrapMode.Wrap;
_ability.verticalOverflow = VerticalWrapMode.Overflow;
UiKit.MakeLiteral(_ability);
UiKit.SetPreferredHeight(_ability, 300f);
_root.gameObject.SetActive(false);
}
public bool IsVisible => _root.gameObject.activeSelf;
public void Show(CardListEntry entry, Action onBack)
{
_onBack = onBack;
var card = entry.Card;
var meta = card != null ? _catalog?.Invoke(card.PrintingId) : null;
if (entry.Hidden || card == null)
{
_art.sprite = CardArt.Back;
_art.color = Color.white;
_name.text = entry.Label ?? "Закрытая карта";
_meta.text = "Содержимое этой карты скрыто.";
_stats.text = string.Empty;
_ability.text = string.Empty;
}
else
{
CardImageService.Apply(
_art,
meta != null ? meta.ImageKey : null,
CardArt.ForCard(
meta != null ? meta.NameId : card.PrintingId,
meta != null ? meta.Clan : "None"));
_name.text = meta != null ? Loc.CardName(meta) : card.PrintingId;
_meta.text = meta != null
? meta.Clan + " · " + meta.Type + " · G" + meta.Grade
+ (string.IsNullOrEmpty(meta.Trigger) || meta.Trigger == "None"
? string.Empty
: " · " + meta.Trigger)
: string.Empty;
_stats.text = "P " + card.Power
+ " · Shield " + (meta != null ? meta.Shield : 0)
+ " · C " + card.Critical;
_ability.text = meta != null ? Loc.CardText(meta) : string.Empty;
}
_root.SetAsLastSibling();
_root.gameObject.SetActive(true);
}
public void Hide()
{
_root.gameObject.SetActive(false);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0fafe71d7dd0d2e479eea533f73ea531

View File

@ -0,0 +1,134 @@
using System.Collections.Generic;
using Vanguard.Board3D;
using Vanguard.Shared.Protocol;
namespace Vanguard.UI.Overlays
{
/// <summary>Один тайл в общем списке карт. Закрытые карты не раскрывают printingId.</summary>
public sealed class CardListEntry
{
public CardListEntry(CardView card, bool hidden = false, string label = null)
{
Card = card;
Hidden = hidden;
Label = label;
}
public CardView Card { get; }
public bool Hidden { get; }
public string Label { get; }
}
/// <summary>Запрос на общий горизонтальный список карт.</summary>
public sealed class CardListRequest
{
public CardListRequest(string title, int player, BoardZone zone)
{
Title = title;
Player = player;
Zone = zone;
}
public string Title { get; }
public int Player { get; }
public BoardZone Zone { get; }
public List<CardListEntry> Entries { get; } = new List<CardListEntry>();
public string EmptyText { get; set; } = "В этой зоне нет карт.";
}
/// <summary>Собирает содержимое зоны для CardListView без раскрытия скрытых данных.</summary>
public static class CardZoneContent
{
public static CardListRequest Build(
PlayerView player,
int viewer,
BoardZone zone)
{
var request = new CardListRequest(ZoneTitle(zone, player), player?.Index ?? 0, zone);
if (player == null)
return request;
switch (zone)
{
case BoardZone.Hand:
if (player.Index == viewer)
AddCards(request, player.Hand);
else
AddHidden(request, player.HandCount, "Закрытая карта");
break;
case BoardZone.Deck:
// Порядок колоды не покидает сервер: показываем только закрытые слоты.
AddHidden(request, player.DeckCount, "Колода");
break;
case BoardZone.Drop:
AddCards(request, player.Drop);
break;
case BoardZone.Soul:
AddCards(request, player.Soul);
break;
case BoardZone.Guardian:
AddCards(request, player.Guardian);
break;
case BoardZone.Trigger:
AddCards(request, player.Trigger);
break;
case BoardZone.Damage:
AddDamage(request, player);
break;
}
return request;
}
private static void AddDamage(CardListRequest request, PlayerView player)
{
var faceUp = player.FaceUpDamage ?? new List<CardView>();
for (var i = faceUp.Count - 1; i >= 0; i--)
request.Entries.Add(new CardListEntry(faceUp[i]));
for (var i = 0; i < player.FaceDownDamage; i++)
request.Entries.Add(new CardListEntry(null, true, "Закрытый урон"));
}
private static void AddCards(CardListRequest request, List<CardView> cards)
{
if (cards == null)
return;
for (var i = cards.Count - 1; i >= 0; i--)
request.Entries.Add(new CardListEntry(cards[i]));
}
private static void AddHidden(CardListRequest request, int count, string label)
{
for (var i = 0; i < count; i++)
request.Entries.Add(new CardListEntry(null, true, label));
}
private static string ZoneTitle(BoardZone zone, PlayerView player)
{
var name = player != null ? player.Name : string.Empty;
var title = zone switch
{
BoardZone.Hand => "Рука",
BoardZone.Deck => "Колода",
BoardZone.Drop => "Дроп",
BoardZone.Soul => "Душа",
BoardZone.Damage => "Урон",
BoardZone.Guardian => "Круг защиты",
BoardZone.Trigger => "Триггер",
BoardZone.Bind => "Bind",
BoardZone.GenerationBreak => "G Zone",
_ => "Зона",
};
return string.IsNullOrEmpty(name) ? title : title + " · " + name;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5790362145aee4c41994f11e199cb921

View File

@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Vanguard.Api.Contracts;
using Vanguard.Shared.Protocol;
namespace Vanguard.UI.Overlays
{
/// <summary>
/// Единый горизонтальный список карт для Deck/Drop/Soul/Damage/Trigger/Hand.
/// Порядок задаёт адаптер: первой идёт карта, лежащая сверху зоны.
/// </summary>
public sealed class CardListView
{
private readonly RectTransform _root;
private readonly RectTransform _content;
private readonly Text _title;
private readonly Func<string, CardCatalogItemDto> _catalog;
private Action<CardListEntry> _onEntry;
private Action _onClose;
public CardListView(
Transform parent,
Func<string, CardCatalogItemDto> catalog)
{
_catalog = catalog;
_root = UiKit.CreatePanel(parent, "CardListView", new Color(0f, 0f, 0f, 0.78f));
var panel = UiKit.CreateCenteredCard(
_root,
"CardListPanel",
new Vector2(980f, 560f),
new Color(0.045f, 0.06f, 0.09f, 1f));
UiKit.AddVerticalLayout(panel, 16, 10);
var header = UiKit.CreatePanel(panel, "Header", Color.clear);
UiKit.AddHorizontalLayout(header, 0, 10);
UiKit.SetPreferredHeight(header, 44f);
_title = UiKit.CreateText(
header,
"Title",
"Карты",
24,
Color.white,
TextAnchor.MiddleLeft);
_title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(_title, 44f);
var close = UiKit.CreateButton(header, "Close", "Закрыть", () => _onClose?.Invoke(), 16);
var closeElement = UiKit.SetPreferredHeight(close, 40f, 0f);
closeElement.preferredWidth = 120f;
var scroll = UiKit.CreateHorizontalScrollView(panel, "CardScroll", out _content, 10f);
var scrollElement = scroll.gameObject.AddComponent<LayoutElement>();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 400f;
_root.gameObject.SetActive(false);
}
public bool IsVisible => _root.gameObject.activeSelf;
public void Show(
CardListRequest request,
Action<CardListEntry> onEntry,
Action onClose)
{
_onEntry = onEntry;
_onClose = onClose;
_title.text = request.Title;
ClearChildren(_content);
if (request.Entries.Count == 0)
{
var empty = UiKit.CreateText(
_content,
"Empty",
request.EmptyText,
17,
new Color(0.62f, 0.68f, 0.82f));
UiKit.SetPreferredHeight(empty, 80f);
}
else
{
foreach (var entry in request.Entries)
AddTile(entry);
}
_root.SetAsLastSibling();
_root.gameObject.SetActive(true);
}
public void Hide()
{
_root.gameObject.SetActive(false);
}
private void AddTile(CardListEntry entry)
{
var tile = UiKit.CreatePanel(
_content,
"Card_" + (entry.Card != null ? entry.Card.InstanceId.ToString() : "hidden"),
Color.white);
var layout = tile.gameObject.AddComponent<LayoutElement>();
layout.preferredWidth = 126f;
layout.preferredHeight = 178f;
layout.flexibleWidth = 0f;
var image = tile.GetComponent<Image>();
image.raycastTarget = true;
var meta = entry.Card != null
? _catalog?.Invoke(entry.Card.PrintingId)
: null;
if (entry.Hidden)
{
image.sprite = CardArt.Back;
image.color = Color.white;
}
else
{
CardImageService.Apply(
image,
meta != null ? meta.ImageKey : null,
CardArt.ForCard(
meta != null ? meta.NameId : entry.Card.PrintingId,
meta != null ? meta.Clan : "None"));
}
var shade = UiKit.CreatePanel(tile, "Shade", new Color(0f, 0f, 0f, 0.76f));
shade.anchorMin = new Vector2(0f, 0f);
shade.anchorMax = new Vector2(1f, 0f);
shade.pivot = new Vector2(0.5f, 0f);
shade.anchoredPosition = Vector2.zero;
shade.sizeDelta = new Vector2(0f, 44f);
shade.GetComponent<Image>().raycastTarget = false;
var name = UiKit.CreateText(
tile,
"Name",
entry.Hidden
? (entry.Label ?? "Закрытая карта")
: (meta != null ? Loc.CardName(meta) : entry.Card.PrintingId),
12,
Color.white,
TextAnchor.LowerCenter);
UiKit.MakeLiteral(name);
var nameRect = name.rectTransform;
nameRect.anchorMin = new Vector2(0f, 0f);
nameRect.anchorMax = new Vector2(1f, 0f);
nameRect.pivot = new Vector2(0.5f, 0f);
nameRect.anchoredPosition = new Vector2(0f, 22f);
nameRect.sizeDelta = new Vector2(-8f, 20f);
name.horizontalOverflow = HorizontalWrapMode.Wrap;
name.verticalOverflow = VerticalWrapMode.Truncate;
name.raycastTarget = false;
var stats = UiKit.CreateText(
tile,
"Stats",
entry.Hidden || entry.Card == null
? string.Empty
: "P " + entry.Card.Power + " C " + entry.Card.Critical,
10,
new Color(0.78f, 0.84f, 0.95f),
TextAnchor.LowerCenter);
UiKit.MakeLiteral(stats);
var statsRect = stats.rectTransform;
statsRect.anchorMin = new Vector2(0f, 0f);
statsRect.anchorMax = new Vector2(1f, 0f);
statsRect.pivot = new Vector2(0.5f, 0f);
statsRect.anchoredPosition = new Vector2(0f, 5f);
statsRect.sizeDelta = new Vector2(-8f, 16f);
stats.raycastTarget = false;
var button = tile.gameObject.AddComponent<Button>();
button.targetGraphic = image;
button.onClick.AddListener(() => _onEntry?.Invoke(entry));
}
private static void ClearChildren(Transform parent)
{
for (var i = parent.childCount - 1; i >= 0; i--)
{
var child = parent.GetChild(i).gameObject;
if (Application.isPlaying)
UnityEngine.Object.Destroy(child);
else
UnityEngine.Object.DestroyImmediate(child);
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6cbcb3db6c46da24b9a93d48776a71da

View File

@ -0,0 +1,93 @@
using System;
using UnityEngine;
using Vanguard.Api.Contracts;
using Vanguard.Board3D;
using Vanguard.Shared.Protocol;
namespace Vanguard.UI.Overlays
{
/// <summary>
/// Один стек экранов поверх боя: список карт → детали карты.
/// Возврат из деталей всегда ведёт обратно в тот же список.
/// </summary>
public sealed class OverlayNavigationService
{
private readonly RectTransform _root;
private readonly CardListView _list;
private readonly CardDetailOverlay _detail;
private CardListRequest _currentRequest;
public OverlayNavigationService(
Transform parent,
Func<string, CardCatalogItemDto> catalog)
{
_root = UiKit.CreatePanel(parent, "BattleOverlays", Color.clear);
_root.gameObject.SetActive(false);
_list = new CardListView(_root, catalog);
_detail = new CardDetailOverlay(_root, catalog);
}
public bool IsOpen => _root.gameObject.activeSelf;
public event Action<bool> OpenChanged;
public void OpenZone(PlayerView player, int viewer, BoardZone zone)
{
OpenList(CardZoneContent.Build(player, viewer, zone));
}
public void OpenList(CardListRequest request)
{
if (request == null)
return;
_currentRequest = request;
_detail.Hide();
_list.Show(request, OpenDetail, CloseAll);
_root.SetAsLastSibling();
_root.gameObject.SetActive(true);
OpenChanged?.Invoke(true);
}
private void OpenDetail(CardListEntry entry)
{
_list.Hide();
_detail.Show(entry, BackToList);
_root.SetAsLastSibling();
_root.gameObject.SetActive(true);
}
private void BackToList()
{
_detail.Hide();
if (_currentRequest == null)
{
CloseAll();
return;
}
_list.Show(_currentRequest, OpenDetail, CloseAll);
}
public void CloseAll()
{
_list?.Hide();
_detail?.Hide();
_root.gameObject.SetActive(false);
_currentRequest = null;
OpenChanged?.Invoke(false);
}
public void Dispose()
{
if (_root == null)
return;
if (Application.isPlaying)
UnityEngine.Object.Destroy(_root.gameObject);
else
UnityEngine.Object.DestroyImmediate(_root.gameObject);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0793b24a2d44b8d4e8450ce1cad7c8ef

View File

@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Vanguard.Board3D;
using Vanguard.Shared.Protocol;
namespace Vanguard.UI.Overlays
{
/// <summary>
/// Кликабельные блоки зон: рука, колода, дроп, душа, урон, защита, триггер.
/// Своя панель снизу слева, противника — сверху справа.
/// </summary>
public sealed class ZoneBlockStripView
{
private static readonly BoardZone[] Zones =
{
BoardZone.Hand,
BoardZone.Deck,
BoardZone.Drop,
BoardZone.Soul,
BoardZone.Damage,
BoardZone.Guardian,
BoardZone.Trigger,
};
private readonly Dictionary<BoardZone, Text> _labels =
new Dictionary<BoardZone, Text>();
private Text _header;
public RectTransform Root { get; private set; }
public event Action<BoardZone> ZoneClicked;
public void Build(
Transform parent,
string name,
Vector2 anchor,
Vector2 pivot,
Vector2 position,
Vector2 size)
{
Root = UiKit.CreatePanel(parent, name, new Color(0.02f, 0.035f, 0.055f, 0.82f));
Root.anchorMin = anchor;
Root.anchorMax = anchor;
Root.pivot = pivot;
Root.anchoredPosition = position;
Root.sizeDelta = size;
var image = Root.GetComponent<Image>();
if (image != null)
image.raycastTarget = false;
UiKit.AddVerticalLayout(Root, 8, 4);
_header = UiKit.CreateText(
Root,
"Header",
string.Empty,
14,
new Color(0.86f, 0.91f, 1f),
TextAnchor.MiddleLeft);
_header.fontStyle = FontStyle.Bold;
UiKit.MakeLiteral(_header);
UiKit.SetPreferredHeight(_header, 20f);
var firstRow = CreateRow("Row1");
var secondRow = CreateRow("Row2");
for (var i = 0; i < Zones.Length; i++)
{
var zone = Zones[i];
var row = i < 4 ? firstRow : secondRow;
var button = UiKit.CreateButton(
row,
"Zone_" + zone,
string.Empty,
() => ZoneClicked?.Invoke(zone),
13);
var element = UiKit.SetPreferredHeight(button, 32f);
element.preferredWidth = i < 4 ? 80f : 108f;
_labels[zone] = button.GetComponentInChildren<Text>();
UiKit.MakeLiteral(_labels[zone]);
}
}
public void Update(PlayerView player, bool self)
{
if (Root == null)
return;
_header.text = Loc.Translate(self ? "ВЫ" : "ПРОТИВНИК")
+ (player != null && !string.IsNullOrEmpty(player.Name)
? " · " + player.Name
: string.Empty);
var hand = player != null
? Mathf.Max(player.HandCount, player.Hand?.Count ?? 0)
: 0;
var deck = player?.DeckCount ?? 0;
var drop = player?.Drop?.Count ?? 0;
var soul = player?.Soul?.Count ?? 0;
var damage = player != null
? player.FaceDownDamage + (player.FaceUpDamage?.Count ?? 0)
: 0;
var guardian = player?.Guardian?.Count ?? 0;
var trigger = player?.Trigger?.Count ?? 0;
Set(BoardZone.Hand, "Рука", hand);
Set(BoardZone.Deck, "Колода", deck);
Set(BoardZone.Drop, "Дроп", drop);
Set(BoardZone.Soul, "Душа", soul);
Set(BoardZone.Damage, "Урон", damage);
Set(BoardZone.Guardian, "Защита", guardian);
Set(BoardZone.Trigger, "Триг.", trigger);
}
private RectTransform CreateRow(string name)
{
var row = UiKit.CreatePanel(Root, name, Color.clear);
var element = row.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = 32f;
UiKit.AddHorizontalLayout(row, 0, 4);
return row;
}
private void Set(BoardZone zone, string label, int count)
{
if (_labels.TryGetValue(zone, out var text) && text != null)
text.text = Loc.Translate(label) + " " + count;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 12e4949ad2c32ff4194ab689bb9408db

View File

@ -0,0 +1,101 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using Vanguard.Board3D;
using Vanguard.Shared.Protocol;
using Vanguard.UI;
using Vanguard.UI.Overlays;
namespace Vanguard.Tests
{
public sealed class CardOverlayTests
{
[Test]
public void Zone_Content_Shows_Last_Added_First_And_Hides_Opponent_Hand()
{
var first = Card("BT01/001EN", 1);
var last = Card("BT01/002EN", 2);
var player = new PlayerView
{
Index = 0,
Hand = new List<CardView> { first, last },
HandCount = 2,
Drop = new List<CardView> { first, last },
FaceUpDamage = new List<CardView> { first },
FaceDownDamage = 2,
Soul = new List<CardView> { first, last },
};
var drop = CardZoneContent.Build(player, viewer: 0, BoardZone.Drop);
Assert.AreSame(last, drop.Entries[0].Card);
var ownHand = CardZoneContent.Build(player, viewer: 0, BoardZone.Hand);
Assert.AreSame(last, ownHand.Entries[0].Card);
var opponentHand = CardZoneContent.Build(player, viewer: 1, BoardZone.Hand);
Assert.AreEqual(2, opponentHand.Entries.Count);
Assert.IsTrue(opponentHand.Entries.All(entry => entry.Hidden));
var damage = CardZoneContent.Build(player, viewer: 0, BoardZone.Damage);
Assert.AreEqual(3, damage.Entries.Count);
Assert.IsTrue(damage.Entries.Skip(1).All(entry => entry.Hidden));
}
[Test]
public void CardList_Opens_Detail_And_Returns_To_List()
{
var canvas = UiKit.CreateCanvas("Overlay Test Canvas");
var overlays = new OverlayNavigationService(canvas.transform, _ => null);
try
{
var request = new CardListRequest("Тест", 0, BoardZone.Drop);
request.Entries.Add(new CardListEntry(Card("BT01/001EN", 1)));
request.Entries.Add(new CardListEntry(null, true, "Закрытая карта"));
overlays.OpenList(request);
Assert.IsTrue(overlays.IsOpen);
Assert.IsTrue(Exists("CardListView"));
var tile = Resources.FindObjectsOfTypeAll<Button>()
.FirstOrDefault(button => button.name.StartsWith("Card_"));
Assert.IsNotNull(tile);
tile.onClick.Invoke();
Assert.IsTrue(Exists("CardDetailOverlay"));
var back = Resources.FindObjectsOfTypeAll<Button>()
.FirstOrDefault(button => button.name == "Back");
Assert.IsNotNull(back);
back.onClick.Invoke();
Assert.IsTrue(Exists("CardListView"));
Assert.IsFalse(Exists("CardDetailOverlay"));
}
finally
{
overlays.Dispose();
Object.DestroyImmediate(canvas.gameObject);
var eventSystem = Object.FindAnyObjectByType<EventSystem>();
if (eventSystem != null)
Object.DestroyImmediate(eventSystem.gameObject);
}
}
private static CardView Card(string id, int instanceId) => new CardView
{
PrintingId = id,
InstanceId = instanceId,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
};
private static bool Exists(string name) =>
Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == name && go.activeInHierarchy);
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a82c53475672b7d47b6e681f28487279

View File

@ -237,19 +237,18 @@ namespace Vanguard.Tests
card.Ref.Zone == BoardZone.Guardian
&& card.Ref.InstanceId == 7));
var selfStats = Resources.FindObjectsOfTypeAll<Text>()
.FirstOrDefault(text => text.name == "Stats"
&& text.transform.parent != null
&& text.transform.parent.name == "SelfStats");
var opponentStats = Resources.FindObjectsOfTypeAll<Text>()
.FirstOrDefault(text => text.name == "Stats"
&& text.transform.parent != null
&& text.transform.parent.name == "OpponentStats");
Assert.IsNotNull(selfStats);
Assert.IsNotNull(opponentStats);
StringAssert.Contains("Рука 1", selfStats.text);
StringAssert.Contains("Душа 1", selfStats.text);
StringAssert.Contains("Рука 0", opponentStats.text);
var selfZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "ZoneBlocksSelf");
var opponentZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "ZoneBlocksOpponent");
Assert.IsNotNull(selfZoneRoot);
Assert.IsNotNull(opponentZoneRoot);
var selfLabels = selfZoneRoot.GetComponentsInChildren<Text>();
var opponentLabels = opponentZoneRoot.GetComponentsInChildren<Text>();
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Рука 1")));
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Душа 1")));
Assert.IsTrue(opponentLabels.Any(text => text.text.Contains("Рука 0")));
Cleanup();
}

View File

@ -32,6 +32,12 @@ namespace Vanguard.Tests
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0));
var opponentHand = cards
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 1)
.ToList();
Assert.AreEqual(3, opponentHand.Count);
Assert.IsTrue(opponentHand.All(card => !card.IsFaceUp));
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0));
@ -90,7 +96,8 @@ namespace Vanguard.Tests
board.Render(BuildView(you: 0), new BoardRenderState());
var hand = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand)
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0)
.ToList();
Assert.AreEqual(2, hand.Count);
@ -235,6 +242,7 @@ namespace Vanguard.Tests
Vanguard = Card("BT01/010EN", 21),
RearGuards = new CardView[5],
Hand = new List<CardView>(),
HandCount = 3,
Guardian = new List<CardView>(),
Soul = new List<CardView>(),
FaceUpDamage = new List<CardView>(),

View File

@ -883,9 +883,31 @@ Battle; Boot и общие сервисы остаются персистент
- Песочница: `Assets/Vanguard/Scenes/GameBoard3D.unity` +
`PlaymatSandbox`; собирается `Vanguard → Rebuild Playmat Sandbox`,
проверяется `SceneSmoke.RunPlaymat`.
- Проверки: EditMode **20/20** (геометрия, зеркальность, правила, рендер
- Проверки: EditMode **22/22** (геометрия, зеркальность, правила, рендер
3D-карт, пул, рука в поле зрения, обмен колоды/дропа, триггер-зона),
`SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=22 hand=5/5 fieldVisible=True`.
`PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
- Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены;
модели карт/стола — процедурные примитивы.
## 2026-10-23 — UX Phase 1: единые списки карт и зоны
- Добавлен общий `CardListView` (Screen Space, горизонтальный ScrollRect,
порядок «последняя добавленная первой») и `CardDetailOverlay` (арт, имя,
клан/тип/грейд, P/Shield/C, триггер, текст). Навигация:
`OverlayNavigationService` — стек «список → детали → назад», закрытие
возвращает к списку.
- Зоны стали кликабельными блоками UI: `Рука / Колода / Дроп / Душа / Урон /
Защита / Триггер`; своя панель снизу слева, противника — сверху справа.
3D-стопки и правый клик по VC используют тот же `CardListView`.
- Скрытые данные: колода — только закрытые слоты по количеству; рука
противника — только рубашки; закрытый урон — «Закрытая карта». Рука
противника дополнительно рисуется веером закрытых карт у дальнего края
стола (вариант 1), identities не запрашиваются.
- Старые `SoulOverlay`/`ShowDrop`/`ShowSoul` удалены; единый оверлей
используется для Deck/Drop/Soul/Damage/Guardian/Trigger/Hand.
- Проверки: клиент EditMode **22/22** (включая `CardListView`/Detail и
зоны), `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
- Дальше по плану: Phase 2 — `InputLockService` + `AnimationConfig` +
контекстное меню; Phase 3 — Power/Shield и стрелка; Phase 4 — Guardian
стопка, Trigger mirror, debug-подписи.

View File

@ -80,12 +80,16 @@
Над колодой — зона проверки триггера. Под картами V/R — динамические
`P/C`.
- Статистика карт — отдельное UI-наложение: своя панель снизу слева,
противника — сверху справа. Душа — юниты под VC: отдельной стопки нет,
противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер`
кликабельны и открывают общий горизонтальный `CardListView`; из списка
открывается `CardDetailOverlay`. Для колоды и руки противника показываются
только закрытые карты. Душа — юниты под VC: отдельной стопки нет,
ПКМ по VC открывает список. Дроп по умолчанию пуст; сверху показывает
последнюю карту, ЛКМ — список всего дропа. Колода и дроп поменяны
местами (колода ближе к игроку). Справа от круга защиты — панель фаз:
конец муллигана (авто после обмена), скип смены, конец главной/боевой;
боевая завершается сама, если нет атак и активаций.
боевая завершается сама, если нет атак и активаций. Рука противника —
веер закрытых карт у дальнего края стола, только рубашки.
- Зона урона: карты лежат открытыми и на боку; после Counter Blast
выбранная карта переворачивается рубашкой. Heal выбирает карту урона;
Counter Blast — открытую карту до активации способности.
@ -148,12 +152,12 @@ curl http://127.0.0.1:18080/health
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
```
Клиентские проверки: **20/20** EditMode (сборка всех экранов, импорт арта,
Клиентские проверки: **22/22** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона),
`SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=22 hand=5/5 fieldVisible=True`.
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
CardListView/Detail, зоны и рука противника), `SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:
@ -179,6 +183,12 @@ client/Assets/Vanguard/Runtime/UI/
LoginView.cs MetaView.cs OnboardingView.cs
DeckBuilderView.cs QueueView.cs BattleView.cs
UiKit.cs Localization.cs
client/Assets/Vanguard/Runtime/UI/Overlays/
OverlayNavigationService.cs стек список → детали
CardListView.cs общий горизонтальный список
CardDetailOverlay.cs крупная карта и текст
CardListModels.cs зоны и скрытые карты
ZoneBlockStripView.cs кликабельные блоки зон
client/Assets/Vanguard/Runtime/Board3D/
Config/PlaymatLayoutConfig.cs ScriptableObject-раскладка
Data/BoardTypes.cs зоны, круги, CardState3D, refs
@ -211,10 +221,12 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
## Следующие шаги
1. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
2. 3D-наполнение: модели карт/стола, prefab-экраны, анимации retire/ghost.
3. Звук и настройки, DOTween.
4. Лаунчер WPF.
1. UX-план 3D-поля: Phase 2 — InputLock/AnimationConfig и контекстное меню;
затем Power/Shield + стрелка, затем Guardian/Trigger/подписи.
2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
3. 3D-наполнение: модели карт/стола, prefab-экраны, анимации retire/ghost.
4. Звук и настройки, DOTween.
5. Лаунчер WPF.
Тренировка с ботом и боевой UI с артами/анимациями/локализацией уже есть.