diff --git a/client/Assets/Vanguard/Editor/SceneSmoke.cs b/client/Assets/Vanguard/Editor/SceneSmoke.cs
index 76d226b..531a43f 100644
--- a/client/Assets/Vanguard/Editor/SceneSmoke.cs
+++ b/client/Assets/Vanguard/Editor/SceneSmoke.cs
@@ -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
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset
index c49ff7c..d98f33d 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset
+++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset
@@ -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
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs
index 7a0a5ee..4867e3e 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs
@@ -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);
}
}
}
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
index a420372..a4a2a43 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
@@ -133,6 +133,27 @@ namespace Vanguard.Board3D
return new HandPose(new Vector3(x, y, z), yaw);
}
+ /// Веер рубашек противника у дальнего края стола.
+ 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);
+ }
+
///
/// Ближайшая цель для броска карты. Возвращает false, если карта
/// отпущена мимо кругов и общего круга защиты.
diff --git a/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs
index 54eb2d1..a32492f 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs
@@ -174,6 +174,7 @@ namespace Vanguard.Board3D
Vanguard = Card("BT01/020EN", 101),
RearGuards = new CardView[5],
Hand = new List(),
+ HandCount = 3,
Guardian = new List(),
Soul = new List { Card("BT01/021EN", 131) },
FaceDownDamage = 1,
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
index efe16b7..753cc46 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
@@ -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 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;
diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs
index e72d971..a20b46d 100644
--- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs
+++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs
@@ -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 _highlightSlots = new HashSet();
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();
- 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();
- 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 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();
- rowElement.preferredHeight = 106f;
- UiKit.AddHorizontalLayout(row, 6, 10);
-
- var art = UiKit.CreatePanel(row, "Art", Color.white);
- var artElement = art.gameObject.AddComponent();
- artElement.preferredWidth = 64f;
- artElement.preferredHeight = 92f;
- artElement.flexibleWidth = 0f;
-
- _catalog.TryGetValue(card.PrintingId, out var meta);
- CardImageService.Apply(
- art.GetComponent(),
- 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())
diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays.meta b/client/Assets/Vanguard/Runtime/UI/Overlays.meta
new file mode 100644
index 0000000..162be1b
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/UI/Overlays.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 7f1073196b40e8f498b8c27ce6722408
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays/CardDetailOverlay.cs b/client/Assets/Vanguard/Runtime/UI/Overlays/CardDetailOverlay.cs
new file mode 100644
index 0000000..a7a0ffd
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/UI/Overlays/CardDetailOverlay.cs
@@ -0,0 +1,137 @@
+using System;
+using UnityEngine;
+using UnityEngine.UI;
+using Vanguard.Api.Contracts;
+
+namespace Vanguard.UI.Overlays
+{
+ /// Крупная карточка с полным текстом. Для скрытых карт показывает рубашку.
+ 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 _catalog;
+ private Action _onBack;
+
+ public CardDetailOverlay(
+ Transform parent,
+ Func 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();
+ bodyElement.flexibleHeight = 1f;
+ bodyElement.minHeight = 500f;
+ UiKit.AddHorizontalLayout(body, 0, 16);
+
+ var artPanel = UiKit.CreatePanel(body, "Art", Color.white);
+ var artElement = artPanel.gameObject.AddComponent();
+ artElement.preferredWidth = 300f;
+ artElement.preferredHeight = 460f;
+ artElement.flexibleWidth = 0f;
+ _art = artPanel.GetComponent();
+ _art.raycastTarget = false;
+
+ var info = UiKit.CreatePanel(body, "Info", Color.clear);
+ var infoElement = info.gameObject.AddComponent();
+ 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);
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays/CardDetailOverlay.cs.meta b/client/Assets/Vanguard/Runtime/UI/Overlays/CardDetailOverlay.cs.meta
new file mode 100644
index 0000000..86e8780
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/UI/Overlays/CardDetailOverlay.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0fafe71d7dd0d2e479eea533f73ea531
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays/CardListModels.cs b/client/Assets/Vanguard/Runtime/UI/Overlays/CardListModels.cs
new file mode 100644
index 0000000..512ad2b
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/UI/Overlays/CardListModels.cs
@@ -0,0 +1,134 @@
+using System.Collections.Generic;
+using Vanguard.Board3D;
+using Vanguard.Shared.Protocol;
+
+namespace Vanguard.UI.Overlays
+{
+ /// Один тайл в общем списке карт. Закрытые карты не раскрывают printingId.
+ 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; }
+ }
+
+ /// Запрос на общий горизонтальный список карт.
+ 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 Entries { get; } = new List();
+ public string EmptyText { get; set; } = "В этой зоне нет карт.";
+ }
+
+ /// Собирает содержимое зоны для CardListView без раскрытия скрытых данных.
+ 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();
+ 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 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;
+ }
+ }
+}
diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays/CardListModels.cs.meta b/client/Assets/Vanguard/Runtime/UI/Overlays/CardListModels.cs.meta
new file mode 100644
index 0000000..887620d
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/UI/Overlays/CardListModels.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5790362145aee4c41994f11e199cb921
\ No newline at end of file
diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays/CardListView.cs b/client/Assets/Vanguard/Runtime/UI/Overlays/CardListView.cs
new file mode 100644
index 0000000..3fe9808
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/UI/Overlays/CardListView.cs
@@ -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
+{
+ ///
+ /// Единый горизонтальный список карт для Deck/Drop/Soul/Damage/Trigger/Hand.
+ /// Порядок задаёт адаптер: первой идёт карта, лежащая сверху зоны.
+ ///
+ public sealed class CardListView
+ {
+ private readonly RectTransform _root;
+ private readonly RectTransform _content;
+ private readonly Text _title;
+ private readonly Func _catalog;
+ private Action _onEntry;
+ private Action _onClose;
+
+ public CardListView(
+ Transform parent,
+ Func 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();
+ scrollElement.flexibleHeight = 1f;
+ scrollElement.minHeight = 400f;
+
+ _root.gameObject.SetActive(false);
+ }
+
+ public bool IsVisible => _root.gameObject.activeSelf;
+
+ public void Show(
+ CardListRequest request,
+ Action 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();
+ layout.preferredWidth = 126f;
+ layout.preferredHeight = 178f;
+ layout.flexibleWidth = 0f;
+
+ var image = tile.GetComponent();
+ 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().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