Lay out full battle field with damage, deck, and guard zones
CI / test (push) Successful in 1m44s Details

This commit is contained in:
MpakobecEx 2026-10-21 22:52:02 +05:00
parent 0e4737ac22
commit cd2d40e4d4
5 changed files with 224 additions and 77 deletions

View File

@ -272,7 +272,8 @@
отдельная 3D-сцена `Battle`, а не текущий Canvas отдельная 3D-сцена `Battle`, а не текущий Canvas
(`docs/client-visual-direction.md`). (`docs/client-visual-direction.md`).
- [x] Поле: Vanguard + 5 кругов Арьергарда, рука, damage/soul/drop-счётчики. - [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева,
колода/дроп справа, зеркально у противника, зона защиты между полями.
- [x] Процедурные карты: цвет/градиент клана, рубашка, имя, ранг, триггер, - [x] Процедурные карты: цвет/градиент клана, рубашка, имя, ранг, триггер,
сила/крит; REST/скрытая карта. сила/крит; REST/скрытая карта.
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»). - [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»).

View File

@ -552,9 +552,9 @@ namespace Vanguard.UI
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You) var self = _view.Players.FirstOrDefault(player => player.Index == _view.You)
?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null); ?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null);
AddFieldBlock(opponent, false); AddSideField(opponent, false);
AddCenterPanel(_view); AddGuardZone(_view);
AddFieldBlock(self, true); AddSideField(self, true);
AddHand(self); AddHand(self);
RebuildIntents(_view); RebuildIntents(_view);
UpdateClock(); UpdateClock();
@ -563,7 +563,7 @@ namespace Vanguard.UI
ShowResult(_view); ShowResult(_view);
} }
private void AddFieldBlock(PlayerView player, bool self) private void AddSideField(PlayerView player, bool self)
{ {
if (player == null) if (player == null)
return; return;
@ -573,8 +573,8 @@ namespace Vanguard.UI
self ? "SelfField" : "OpponentField", self ? "SelfField" : "OpponentField",
new Color(0.05f, 0.07f, 0.11f, 0.96f)); new Color(0.05f, 0.07f, 0.11f, 0.96f));
var blockElement = block.gameObject.AddComponent<LayoutElement>(); var blockElement = block.gameObject.AddComponent<LayoutElement>();
blockElement.preferredHeight = self ? 214f : 204f; blockElement.preferredHeight = 334f;
UiKit.AddVerticalLayout(block, 7, 3); UiKit.AddVerticalLayout(block, 5, 3);
var header = UiKit.CreateText( var header = UiKit.CreateText(
block, block,
@ -589,75 +589,160 @@ namespace Vanguard.UI
self ? Color.white : new Color(0.94f, 0.82f, 0.70f), self ? Color.white : new Color(0.94f, 0.82f, 0.70f),
TextAnchor.MiddleLeft); TextAnchor.MiddleLeft);
header.fontStyle = FontStyle.Bold; header.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(header, 25f); UiKit.SetPreferredHeight(header, 24f);
var row = UiKit.CreatePanel(block, "Field", new Color(0f, 0f, 0f, 0f)); var body = UiKit.CreatePanel(block, "Body", new Color(0f, 0f, 0f, 0f));
var rowElement = row.gameObject.AddComponent<LayoutElement>(); var bodyElement = body.gameObject.AddComponent<LayoutElement>();
rowElement.flexibleHeight = 1f; bodyElement.flexibleHeight = 1f;
rowElement.minHeight = 166f; bodyElement.minHeight = 294f;
UiKit.AddHorizontalLayout(row, 3, 7); UiKit.AddHorizontalLayout(body, 0, 8);
AddCardBox(row, player.Vanguard, "V", compact: false, player.Index, -1); if (self)
for (var i = 0; i < 5; i++)
{ {
var card = player.RearGuards != null && i < player.RearGuards.Length AddDamageColumn(body, player);
? player.RearGuards[i] AddFieldGrid(body, player, self);
: null; AddPileColumn(body, player);
AddCardBox(row, card, "R" + (i + 1), compact: false, player.Index, i); }
else
{
AddPileColumn(body, player);
AddFieldGrid(body, player, self);
AddDamageColumn(body, player);
} }
} }
private void AddCenterPanel(MatchView view) private void AddFieldGrid(Transform parent, PlayerView player, bool self)
{ {
var panel = UiKit.CreatePanel(_board, "MatchInfo", new Color(0.06f, 0.09f, 0.15f, 0.98f)); var grid = UiKit.CreatePanel(parent, "FieldGrid", new Color(0f, 0f, 0f, 0f));
var element = panel.gameObject.AddComponent<LayoutElement>(); var gridElement = grid.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = 76f; gridElement.flexibleWidth = 1f;
UiKit.AddVerticalLayout(panel, 8, 2); gridElement.flexibleHeight = 1f;
var active = view.ActivePlayer == view.You ? "решаете вы" : "решает противник"; var layout = grid.gameObject.AddComponent<GridLayoutGroup>();
var main = UiKit.CreateText( layout.cellSize = new Vector2(140f, 140f);
panel, layout.spacing = new Vector2(6f, 6f);
"Main", layout.padding = new RectOffset(4, 4, 4, 4);
"Ход " + view.TurnNumber + " · " + view.Phase + " · " + view.Step layout.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
+ " · " + active, layout.constraintCount = 3;
17, layout.childAlignment = TextAnchor.MiddleCenter;
view.ActivePlayer == view.You layout.startCorner = GridLayoutGroup.Corner.UpperLeft;
? new Color(0.55f, 0.86f, 1f) layout.startAxis = GridLayoutGroup.Axis.Horizontal;
: new Color(1f, 0.78f, 0.52f),
// Первый ряд: R V R. Второй ряд: R R R.
// Для противника ряды зеркальны: его V-ряд ближе к центру.
var slots = self
? new[] { 3, -1, 4, 0, 1, 2 }
: new[] { 0, 1, 2, 3, -1, 4 };
foreach (var slot in slots)
{
var card = CardAt(player.Index, slot);
AddCardBox(
grid,
card,
slot == -1 ? "V" : "R" + (slot + 1),
compact: false,
player.Index,
slot);
}
}
private void AddDamageColumn(Transform parent, PlayerView player)
{
var column = UiKit.CreatePanel(parent, "DamageColumn", new Color(0f, 0f, 0f, 0f));
var element = column.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = 88f;
element.minWidth = 80f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(column, 0, 5);
AddPile(column, "УРОН",
player.FaceDownDamage + player.FaceUpDamage.Count,
new Color(0.48f, 0.12f, 0.12f), 118f);
AddPile(column, "SOUL", player.Soul.Count,
new Color(0.26f, 0.18f, 0.42f), 96f);
}
private void AddPileColumn(Transform parent, PlayerView player)
{
var column = UiKit.CreatePanel(parent, "PileColumn", new Color(0f, 0f, 0f, 0f));
var element = column.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = 88f;
element.minWidth = 80f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(column, 0, 5);
AddPile(column, "КОЛОДА", player.DeckCount,
new Color(0.12f, 0.22f, 0.42f), 118f);
AddPile(column, "ДРОП", player.Drop.Count,
new Color(0.20f, 0.20f, 0.26f), 96f);
}
private void AddPile(Transform parent, string title, int count, Color color, float height)
{
var pile = UiKit.CreatePanel(parent, "Pile_" + title, color);
var element = pile.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = height;
element.flexibleWidth = 1f;
var outline = pile.gameObject.AddComponent<Outline>();
outline.effectColor = new Color(1f, 1f, 1f, 0.15f);
outline.effectDistance = new Vector2(1f, -1f);
UiKit.AddVerticalLayout(pile, 5, 1);
var label = UiKit.CreateText(
pile, "Title", title, 11, new Color(1f, 1f, 1f, 0.75f));
UiKit.SetPreferredHeight(label, 18f);
var value = UiKit.CreateText(
pile, "Count", count.ToString(), 26, Color.white);
value.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(value, 38f);
}
private void AddGuardZone(MatchView view)
{
var guard = UiKit.CreatePanel(
_board, "GuardZone", new Color(0.08f, 0.11f, 0.18f, 0.98f));
var element = guard.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = 72f;
UiKit.AddHorizontalLayout(guard, 14, 12);
var label = UiKit.CreateText(
guard,
"Label",
"ЗОНА ЗАЩИТЫ",
18,
new Color(0.82f, 0.88f, 1f),
TextAnchor.MiddleLeft); TextAnchor.MiddleLeft);
main.fontStyle = FontStyle.Bold; label.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(main, 26f); UiKit.SetPreferredHeight(label, 32f, 0f);
var details = new List<string>(); var shield = UiKit.CreateText(
if (view.Battle != null) guard,
details.Add("Бой: " + SlotName(view.Battle.AttackerSlot) "Shield",
+ " → " + SlotName(view.Battle.TargetSlot) "Щит: " + (view.Battle != null ? view.Battle.GuardShield.ToString() : "—"),
+ " · щит " + view.Battle.GuardShield 18,
+ " · проверок " + view.Battle.ChecksRemaining); new Color(0.55f, 0.86f, 1f),
if (view.Choice != null) TextAnchor.MiddleCenter);
details.Add(view.Choice.Prompt); UiKit.SetPreferredHeight(shield, 32f, 0f);
if (view.Ability != null)
details.Add("Способность: " + CardName(view.Ability.PrintingId));
if (view.Trigger != null)
details.Add("Триггер: " + CardName(view.Trigger.PrintingId)
+ " · " + view.Trigger.Trigger);
if (view.OpponentChoosing)
details.Add("Противник выбирает действие…");
if (details.Count == 0)
details.Add("Выберите действие в панели справа.");
var detail = UiKit.CreateText( var guardian = UiKit.CreateText(
panel, "Detail", string.Join(" · ", details), 14, guard,
Muted, TextAnchor.MiddleLeft); "Guardian",
detail.horizontalOverflow = HorizontalWrapMode.Wrap; "Проверок: " + (view.Battle != null ? view.Battle.ChecksRemaining : 0)
UiKit.SetPreferredHeight(detail, 28f); + " · защита между полями",
14,
Muted,
TextAnchor.MiddleRight);
UiKit.SetPreferredHeight(guardian, 32f);
} }
private void AddHand(PlayerView self) private void AddHand(PlayerView self)
{ {
var hand = UiKit.CreatePanel(_board, "HandBlock", new Color(0.05f, 0.07f, 0.11f, 0.96f)); var hand = UiKit.CreatePanel(_board, "HandBlock", new Color(0.05f, 0.07f, 0.11f, 0.96f));
var handElement = hand.gameObject.AddComponent<LayoutElement>(); var handElement = hand.gameObject.AddComponent<LayoutElement>();
handElement.preferredHeight = 188f; handElement.preferredHeight = 164f;
UiKit.AddVerticalLayout(hand, 6, 3); UiKit.AddVerticalLayout(hand, 6, 3);
var header = UiKit.CreateText( var header = UiKit.CreateText(
@ -670,7 +755,7 @@ namespace Vanguard.UI
var scroll = UiKit.CreateHorizontalScrollView(hand, "HandScroll", out var content, 6f); var scroll = UiKit.CreateHorizontalScrollView(hand, "HandScroll", out var content, 6f);
var scrollElement = scroll.gameObject.AddComponent<LayoutElement>(); var scrollElement = scroll.gameObject.AddComponent<LayoutElement>();
scrollElement.flexibleHeight = 1f; scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 130f; scrollElement.minHeight = 124f;
if (self?.Hand == null || self.Hand.Count == 0) if (self?.Hand == null || self.Hand.Count == 0)
{ {
@ -714,8 +799,8 @@ namespace Vanguard.UI
? new Color(0.09f, 0.11f, 0.16f, 1f) ? new Color(0.09f, 0.11f, 0.16f, 1f)
: new Color(0.05f, 0.08f, 0.14f, 1f)); : new Color(0.05f, 0.08f, 0.14f, 1f));
var element = panel.gameObject.AddComponent<LayoutElement>(); var element = panel.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = compact ? 106f : 124f; element.preferredWidth = compact ? 104f : 140f;
element.preferredHeight = compact ? 142f : 162f; element.preferredHeight = compact ? 124f : 140f;
element.flexibleWidth = 0f; element.flexibleWidth = 0f;
var highlighted = _highlightKeys.Contains( var highlighted = _highlightKeys.Contains(
@ -740,21 +825,20 @@ namespace Vanguard.UI
if (card == null) if (card == null)
{ {
var emptyArt = UiKit.CreatePanel(panel, "EmptyArt", new Color(0.04f, 0.06f, 0.09f, 1f)); var panelImage = panel.GetComponent<Image>();
UiKit.SetPreferredHeight(emptyArt, compact ? 48f : 64f); panelImage.sprite = CardArt.Zone;
panelImage.color = Color.white;
panelImage.preserveAspect = true;
var empty = UiKit.CreateText( var empty = UiKit.CreateText(
emptyArt, "Empty", "—", 22, panel, "EmptyLabel", "Пусто", 12,
new Color(1f, 1f, 1f, 0.18f)); new Color(1f, 1f, 1f, 0.40f), TextAnchor.MiddleCenter);
UiKit.Stretch(empty.rectTransform); UiKit.SetPreferredHeight(empty, compact ? 100f : 116f);
var emptyLabel = UiKit.CreateText(
panel, "EmptyLabel", "Пусто", 11,
new Color(1f, 1f, 1f, 0.28f));
UiKit.SetPreferredHeight(emptyLabel, 24f);
return; return;
} }
var art = UiKit.CreatePanel(panel, "Art", Color.white); var art = UiKit.CreatePanel(panel, "Art", Color.white);
UiKit.SetPreferredHeight(art, compact ? 48f : 64f); UiKit.SetPreferredHeight(art, compact ? 46f : 58f);
var artImage = art.GetComponent<Image>(); var artImage = art.GetComponent<Image>();
artImage.sprite = card.FaceUp artImage.sprite = card.FaceUp
? CardArt.ForCard( ? CardArt.ForCard(
@ -815,7 +899,7 @@ namespace Vanguard.UI
TextAnchor.UpperLeft); TextAnchor.UpperLeft);
name.horizontalOverflow = HorizontalWrapMode.Wrap; name.horizontalOverflow = HorizontalWrapMode.Wrap;
name.fontStyle = FontStyle.Bold; name.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(name, compact ? 26f : 28f); UiKit.SetPreferredHeight(name, compact ? 22f : 24f);
var stats = UiKit.CreateText( var stats = UiKit.CreateText(
panel, "Stats", panel, "Stats",
@ -823,12 +907,12 @@ namespace Vanguard.UI
? "P " + card.Power + " · C " + card.Critical ? "P " + card.Power + " · C " + card.Critical
+ (meta != null ? " · " + TriggerShort(meta.Trigger) : string.Empty) + (meta != null ? " · " + TriggerShort(meta.Trigger) : string.Empty)
: "СКРЫТА", : "СКРЫТА",
11, 10,
card.FaceUp && card.Power != card.BasePower card.FaceUp && card.Power != card.BasePower
? new Color(1f, 0.82f, 0.42f) ? new Color(1f, 0.82f, 0.42f)
: new Color(0.72f, 0.78f, 0.92f), : new Color(0.72f, 0.78f, 0.92f),
TextAnchor.MiddleLeft); TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(stats, 18f); UiKit.SetPreferredHeight(stats, 16f);
} }
private static string TriggerShort(string trigger) => trigger switch private static string TriggerShort(string trigger) => trigger switch

View File

@ -12,6 +12,7 @@ namespace Vanguard.UI
private static readonly Dictionary<string, Sprite> Cache = private static readonly Dictionary<string, Sprite> Cache =
new Dictionary<string, Sprite>(); new Dictionary<string, Sprite>();
private static Sprite _back; private static Sprite _back;
private static Sprite _zone;
public static Color ClanColor(string clan) => clan switch public static Color ClanColor(string clan) => clan switch
{ {
@ -89,6 +90,63 @@ namespace Vanguard.UI
} }
} }
public static Sprite Zone
{
get
{
if (_zone != null)
return _zone;
_zone = ZoneTexture();
return _zone;
}
}
private static Sprite ZoneTexture()
{
const int size = 64;
var texture = new Texture2D(size, size, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp,
};
var pixels = new Color[size * size];
var center = (size - 1) * 0.5f;
for (var y = 0; y < size; y++)
{
for (var x = 0; x < size; x++)
{
var distance = Mathf.Sqrt((x - center) * (x - center) + (y - center) * (y - center));
if (distance > 30f)
{
pixels[y * size + x] = Color.clear;
}
else if (distance > 26f)
{
pixels[y * size + x] = new Color(0.38f, 0.52f, 0.78f, 0.9f);
}
else
{
var glow = Mathf.Clamp01(1f - distance / 26f) * 0.08f;
pixels[y * size + x] = new Color(
0.04f + glow,
0.07f + glow,
0.12f + glow,
0.95f);
}
}
}
texture.SetPixels(pixels);
texture.Apply();
return Sprite.Create(
texture,
new Rect(0f, 0f, size, size),
new Vector2(0.5f, 0.5f),
100f);
}
private static Sprite Gradient(Color bottom, Color top, Color accent, int variant) private static Sprite Gradient(Color bottom, Color top, Color accent, int variant)
{ {
const int width = 32; const int width = 32;

View File

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

View File

@ -56,8 +56,10 @@
создаёт casual-матч против случайного бота (`POST /api/game/practice`), создаёт casual-матч против случайного бота (`POST /api/game/practice`),
без истории, MMR и наград. без истории, MMR и наград.
- Боевой экран: процедурные карты с арт-градиентом по клану, рамкой, - Боевой экран: процедурные карты с арт-градиентом по клану, рамкой,
именем, рангом, триггером, силой/критом и рубашкой; поля Vanguard + именем, рангом, триггером, силой/критом и рубашкой.
5 Арьергардов у обоих, рука, damage/soul/drop, REST/скрытые карты. - Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
(+soul), справа колода и ниже дроп; у противника зеркально; между
полями — зона защиты со щитом и проверками.
- Взаимодействие в бою: клик по картам/зонам, подсветка целей, - Взаимодействие в бою: клик по картам/зонам, подсветка целей,
группировка действий (карты → цели, глобальные кнопки отдельно), группировка действий (карты → цели, глобальные кнопки отдельно),
журнал последних событий. журнал последних событий.