Add procedural card graphics and battle board
CI / test (push) Successful in 5m15s Details

This commit is contained in:
MpakobecEx 2026-10-21 19:56:57 +05:00
parent e0deecacd5
commit 0e528586b4
10 changed files with 471 additions and 141 deletions

View File

@ -266,13 +266,15 @@
## 8. Клиент: боевая сцена `[20–30 дней]`
**Первый gray-box есть:** доска, рука, сила/крит, часы, все легальные
интенты кнопками, сдача и результат. Арты и анимации — следующие.
Финальный визуал — отдельная 3D-сцена `Battle`, а не текущий Canvas
**Визуальный каркас есть:** доска, рука, карточные рамки/арт-заглушки
по клану, сила/крит, часы, все легальные интенты кнопками, сдача и
результат. Реальные арты и анимации — следующие. Финальный визуал —
отдельная 3D-сцена `Battle`, а не текущий Canvas
(`docs/client-visual-direction.md`).
- [x] Поле: Vanguard, 5 кругов Арьергарда, рука, damage/soul/drop-счётчики
(без артов и анимаций).
- [x] Поле: Vanguard + 5 кругов Арьергарда, рука, damage/soul/drop-счётчики.
- [x] Процедурные карты: цвет/градиент клана, рубашка, имя, ранг, триггер,
сила/крит; REST/скрытая карта.
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»).
- [x] Ввод: кнопки легальных интентов; подсветка целей — дальше.
- [x] Отображение силы/крита из `MatchView`; модификаторы уже посчитаны сервером.

View File

@ -21,7 +21,7 @@
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
| EF-миграции | готово (`InitialCreate`) |
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, gray-box бой |
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с процедурными картами |
| Лаунчер | не начато |
## Требования

View File

@ -27,6 +27,9 @@ namespace Vanguard.UI
private readonly MetaService _meta;
private readonly Action _onExit;
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
new Dictionary<string, CardCatalogItemDto>(StringComparer.Ordinal);
private bool _catalogLoading;
private readonly RectTransform _root;
private readonly RectTransform _board;
@ -138,6 +141,7 @@ namespace Vanguard.UI
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
_ = LoadCatalogAsync();
_ = ConnectAsync();
}
@ -191,6 +195,31 @@ namespace Vanguard.UI
return overlay;
}
private async Task LoadCatalogAsync()
{
if (_catalogLoading || _catalog.Count > 0)
return;
_catalogLoading = true;
try
{
var items = await _meta.GetCatalogAsync();
foreach (var item in items)
_catalog[item.PrintingId] = item;
if (_root.gameObject.activeSelf && _view != null)
Render();
}
catch (Exception exception)
{
Debug.LogWarning("Battle catalog load failed: " + exception.Message);
}
finally
{
_catalogLoading = false;
}
}
private async Task ConnectAsync()
{
if (_connecting || _info == null)
@ -430,9 +459,9 @@ namespace Vanguard.UI
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You)
?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null);
AddPlayerBlock(opponent, false);
AddMatchInfo(_view);
AddPlayerBlock(self, true);
AddFieldBlock(opponent, false);
AddCenterPanel(_view);
AddFieldBlock(self, true);
AddHand(self);
RebuildIntents(_view);
UpdateClock();
@ -441,167 +470,270 @@ namespace Vanguard.UI
ShowResult(_view);
}
private void AddPlayerBlock(PlayerView player, bool self)
private void AddFieldBlock(PlayerView player, bool self)
{
if (player == null)
return;
var block = UiKit.CreatePanel(
_board,
self ? "SelfBlock" : "OpponentBlock",
Panel);
self ? "SelfField" : "OpponentField",
new Color(0.05f, 0.07f, 0.11f, 0.96f));
var blockElement = block.gameObject.AddComponent<LayoutElement>();
blockElement.preferredHeight = self ? 250f : 230f;
UiKit.AddVerticalLayout(block, 8, 4);
blockElement.preferredHeight = self ? 214f : 204f;
UiKit.AddVerticalLayout(block, 7, 3);
var header = UiKit.CreateText(
block,
"Header",
(self ? "Вы: " : "Противник: ") + player.Name
+ " · рука " + player.HandCount
+ " · колода " + player.DeckCount
+ " · урон " + (player.FaceDownDamage + player.FaceUpDamage.Count)
+ " · soul " + player.Soul.Count
+ " · drop " + player.Drop.Count,
17,
self ? Color.white : new Color(0.86f, 0.80f, 0.72f),
(self ? "ВЫ" : "ПРОТИВНИК") + " · " + player.Name
+ " рука " + player.HandCount
+ " · колода " + player.DeckCount
+ " · урон " + (player.FaceDownDamage + player.FaceUpDamage.Count)
+ " · soul " + player.Soul.Count
+ " · drop " + player.Drop.Count,
15,
self ? Color.white : new Color(0.94f, 0.82f, 0.70f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(header, 26f);
header.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(header, 25f);
var row = UiKit.CreatePanel(block, "Field", new Color(0f, 0f, 0f, 0f));
var rowElement = row.gameObject.AddComponent<LayoutElement>();
rowElement.flexibleHeight = 1f;
rowElement.minHeight = 150f;
UiKit.AddHorizontalLayout(row, 0, 6);
rowElement.minHeight = 166f;
UiKit.AddHorizontalLayout(row, 3, 7);
AddCardBox(row, player.Vanguard, "Vanguard");
AddCardBox(row, player.Vanguard, "V", compact: false);
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));
AddCardBox(row, card, "R" + (i + 1), compact: false);
}
}
private void AddCenterPanel(MatchView view)
{
var panel = UiKit.CreatePanel(_board, "MatchInfo", new Color(0.06f, 0.09f, 0.15f, 0.98f));
var element = panel.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = 76f;
UiKit.AddVerticalLayout(panel, 8, 2);
var active = view.ActivePlayer == view.You ? "решаете вы" : "решает противник";
var main = UiKit.CreateText(
panel,
"Main",
"Ход " + view.TurnNumber + " · " + view.Phase + " · " + view.Step
+ " · " + active,
17,
view.ActivePlayer == view.You
? new Color(0.55f, 0.86f, 1f)
: new Color(1f, 0.78f, 0.52f),
TextAnchor.MiddleLeft);
main.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(main, 26f);
var details = new List<string>();
if (view.Battle != null)
details.Add("Бой: " + SlotName(view.Battle.AttackerSlot)
+ " → " + SlotName(view.Battle.TargetSlot)
+ " · щит " + view.Battle.GuardShield
+ " · проверок " + view.Battle.ChecksRemaining);
if (view.Choice != null)
details.Add(view.Choice.Prompt);
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(
panel, "Detail", string.Join(" · ", details), 14,
Muted, TextAnchor.MiddleLeft);
detail.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(detail, 28f);
}
private void AddHand(PlayerView self)
{
var hand = UiKit.CreatePanel(_board, "HandBlock", Panel);
var hand = UiKit.CreatePanel(_board, "HandBlock", new Color(0.05f, 0.07f, 0.11f, 0.96f));
var handElement = hand.gameObject.AddComponent<LayoutElement>();
handElement.preferredHeight = 190f;
UiKit.AddVerticalLayout(hand, 8, 4);
handElement.preferredHeight = 188f;
UiKit.AddVerticalLayout(hand, 6, 3);
var header = UiKit.CreateText(
hand, "Header", "Рука", 17, Color.white, TextAnchor.MiddleLeft);
hand, "Header",
"РУКА · " + (self?.Hand?.Count ?? 0) + " карт",
16, Color.white, TextAnchor.MiddleLeft);
header.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(header, 22f);
var row = UiKit.CreatePanel(hand, "Cards", new Color(0f, 0f, 0f, 0f));
var rowElement = row.gameObject.AddComponent<LayoutElement>();
rowElement.flexibleHeight = 1f;
UiKit.AddHorizontalLayout(row, 0, 6);
var scroll = UiKit.CreateHorizontalScrollView(hand, "HandScroll", out var content, 6f);
var scrollElement = scroll.gameObject.AddComponent<LayoutElement>();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 130f;
if (self?.Hand == null || self.Hand.Count == 0)
{
var empty = UiKit.CreateText(row, "Empty", "Рука пуста", 18, Muted);
UiKit.SetPreferredHeight(empty, 120f);
var empty = UiKit.CreateText(
content, "Empty", "Рука пуста", 17, Muted);
UiKit.SetPreferredHeight(empty, 110f);
return;
}
foreach (var card in self.Hand)
AddCardBox(row, card, "Рука");
AddCardBox(content, card, "Рука", compact: true);
}
private void AddMatchInfo(MatchView view)
{
var info = UiKit.CreatePanel(_board, "MatchInfo", Panel);
var element = info.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = 150f;
UiKit.AddVerticalLayout(info, 10, 3);
var active = view.ActivePlayer == view.You ? "вы" : "противник";
AddInfoLine(info, "Фаза: " + view.Phase + " · шаг: " + view.Step
+ " · ход " + view.TurnNumber + " · решает: " + active);
if (view.Battle != null)
{
AddInfoLine(
info,
"Атака: " + SlotName(view.Battle.AttackerSlot)
+ " → " + SlotName(view.Battle.TargetSlot)
+ " · щит " + view.Battle.GuardShield
+ " · проверок " + view.Battle.ChecksRemaining
+ (view.Battle.Nullified ? " · отменено" : string.Empty));
}
if (view.Choice != null)
AddInfoLine(info, "Выбор: " + view.Choice.Prompt);
if (view.Ability != null)
AddInfoLine(
info,
"Способность: " + view.Ability.PrintingId
+ (view.Ability.Optional ? " (опционально)" : string.Empty));
if (view.Trigger != null)
{
AddInfoLine(
info,
"Триггер: " + view.Trigger.PrintingId
+ " / " + view.Trigger.Trigger
+ " / урон " + view.Trigger.RemainingDamage);
}
if (view.Order != null && view.Order.Options.Count > 0)
AddInfoLine(info, "Нужно выбрать порядок эффектов: " + view.Order.Options.Count);
if (view.OpponentChoosing)
AddInfoLine(info, "Противник что-то выбирает…");
}
private static void AddInfoLine(Transform parent, string text)
private void AddInfoLine(Transform parent, string text)
{
var line = UiKit.CreateText(
parent, "Info", text, 16, Muted, TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(line, 22f);
}
private static void AddCardBox(Transform parent, CardView card, string slot)
private void AddCardBox(Transform parent, CardView card, string slot, bool compact)
{
var panel = UiKit.CreatePanel(parent, "Card_" + slot, Card);
var element = panel.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = 132f;
element.preferredHeight = 148f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(panel, 5, 2);
var meta = card != null && _catalog.TryGetValue(card.PrintingId, out var found)
? found
: null;
var clanColor = CardArt.ClanColor(meta != null ? meta.Clan : "None");
var frameColor = card == null
? new Color(0.16f, 0.18f, 0.24f)
: CardArt.ClanColor(meta != null ? meta.Clan : "None");
var slotText = UiKit.CreateText(panel, "Slot", slot, 12, Muted);
UiKit.SetPreferredHeight(slotText, 16f);
var panel = UiKit.CreatePanel(
parent, "Card_" + slot, card == null
? new Color(0.06f, 0.08f, 0.12f, 0.9f)
: card.FaceUp
? new Color(0.09f, 0.11f, 0.16f, 1f)
: new Color(0.05f, 0.08f, 0.14f, 1f));
var element = panel.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = compact ? 106f : 124f;
element.preferredHeight = compact ? 142f : 162f;
element.flexibleWidth = 0f;
var outline = panel.gameObject.AddComponent<Outline>();
outline.effectColor = frameColor;
outline.effectDistance = new Vector2(2f, -2f);
UiKit.AddVerticalLayout(panel, 4, 2);
var slotText = UiKit.CreateText(
panel, "Slot", slot, 10,
new Color(0.58f, 0.66f, 0.80f), TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(slotText, 14f);
if (card == null)
{
var empty = UiKit.CreateText(panel, "Empty", "Пусто", 14, new Color(1f, 1f, 1f, 0.35f));
UiKit.SetPreferredHeight(empty, 60f);
var emptyArt = UiKit.CreatePanel(panel, "EmptyArt", new Color(0.04f, 0.06f, 0.09f, 1f));
UiKit.SetPreferredHeight(emptyArt, compact ? 48f : 64f);
var empty = UiKit.CreateText(
emptyArt, "Empty", "—", 22,
new Color(1f, 1f, 1f, 0.18f));
UiKit.Stretch(empty.rectTransform);
var emptyLabel = UiKit.CreateText(
panel, "EmptyLabel", "Пусто", 11,
new Color(1f, 1f, 1f, 0.28f));
UiKit.SetPreferredHeight(emptyLabel, 24f);
return;
}
var printing = UiKit.CreateText(
panel, "Printing", card.PrintingId, 12, Color.white, TextAnchor.UpperCenter);
printing.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(printing, 42f);
var art = UiKit.CreatePanel(panel, "Art", Color.white);
UiKit.SetPreferredHeight(art, compact ? 48f : 64f);
var artImage = art.GetComponent<Image>();
artImage.sprite = card.FaceUp
? CardArt.ForCard(
meta != null ? meta.NameId : card.PrintingId,
meta != null ? meta.Clan : "None")
: CardArt.Back;
artImage.color = Color.white;
artImage.preserveAspect = false;
if (card.FaceUp)
{
var artName = UiKit.CreateText(
art, "ArtName", meta != null ? meta.NameEn : card.PrintingId, 10,
new Color(1f, 1f, 1f, 0.94f), TextAnchor.MiddleCenter);
UiKit.Stretch(artName.rectTransform);
artName.rectTransform.offsetMin = new Vector2(3f, 3f);
artName.rectTransform.offsetMax = new Vector2(-3f, -2f);
artName.horizontalOverflow = HorizontalWrapMode.Wrap;
if (meta != null)
{
var grade = UiKit.CreateText(
art, "Grade", "G" + meta.Grade, 10,
Color.white, TextAnchor.MiddleCenter);
var gradeRect = grade.rectTransform;
gradeRect.anchorMin = new Vector2(1f, 1f);
gradeRect.anchorMax = new Vector2(1f, 1f);
gradeRect.pivot = new Vector2(1f, 1f);
gradeRect.anchoredPosition = new Vector2(-3f, -3f);
gradeRect.sizeDelta = new Vector2(25f, 16f);
}
}
else
{
var hidden = UiKit.CreateText(
art, "Hidden", "?", 26, new Color(1f, 1f, 1f, 0.8f));
UiKit.Stretch(hidden.rectTransform);
}
if (card.Rested)
{
var rested = UiKit.CreateText(
art, "Rested", "REST", 10,
new Color(1f, 0.55f, 0.35f), TextAnchor.MiddleCenter);
var restedRect = rested.rectTransform;
restedRect.anchorMin = new Vector2(0f, 0f);
restedRect.anchorMax = new Vector2(0f, 0f);
restedRect.pivot = new Vector2(0f, 0f);
restedRect.anchoredPosition = new Vector2(3f, 3f);
restedRect.sizeDelta = new Vector2(34f, 16f);
}
var name = UiKit.CreateText(
panel, "Name",
meta != null ? meta.NameEn : card.PrintingId,
10,
card.FaceUp ? Color.white : Muted,
TextAnchor.UpperLeft);
name.horizontalOverflow = HorizontalWrapMode.Wrap;
name.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(name, compact ? 26f : 28f);
var stats = UiKit.CreateText(
panel, "Stats", card.Power + " / " + card.Critical, 16, Color.white);
UiKit.SetPreferredHeight(stats, 22f);
var stateText = (card.Rested ? "REST " : string.Empty)
+ (card.FaceUp ? string.Empty : "FACE-DOWN");
if (!string.IsNullOrEmpty(stateText))
{
var state = UiKit.CreateText(panel, "State", stateText.Trim(), 12, new Color(1f, 0.76f, 0.38f));
UiKit.SetPreferredHeight(state, 18f);
}
panel, "Stats",
card.FaceUp
? "P " + card.Power + " · C " + card.Critical
+ (meta != null ? " · " + TriggerShort(meta.Trigger) : string.Empty)
: "СКРЫТА",
11,
card.FaceUp && card.Power != card.BasePower
? new Color(1f, 0.82f, 0.42f)
: new Color(0.72f, 0.78f, 0.92f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(stats, 18f);
}
private static string TriggerShort(string trigger) => trigger switch
{
"Critical" => "K",
"Draw" => "D",
"Stand" => "S",
"Heal" => "H",
_ => "—",
};
private string CardName(string printingId) =>
_catalog.TryGetValue(printingId, out var meta) ? meta.NameEn : printingId;
private void RebuildIntents(MatchView view)
{
ClearChildren(_intents);
@ -681,7 +813,13 @@ namespace Vanguard.UI
private static void ClearChildren(Transform parent)
{
for (var i = parent.childCount - 1; i >= 0; i--)
UnityEngine.Object.Destroy(parent.GetChild(i).gameObject);
{
var child = parent.GetChild(i).gameObject;
if (Application.isPlaying)
UnityEngine.Object.Destroy(child);
else
UnityEngine.Object.DestroyImmediate(child);
}
}
private static string SlotName(int slot)
@ -699,14 +837,14 @@ namespace Vanguard.UI
case FinishMulliganIntent _: return "Закончить маллиган";
case MulliganIntent mulligan: return "Маллиган: " + mulligan.HandIndexes.Count + " карт";
case SkipRideIntent _: return "Пропустить Ride";
case RideIntent ride: return "Ride: " + ride.PrintingId;
case CallIntent call: return "Call " + call.PrintingId + " → " + SlotName(call.Slot);
case RideIntent ride: return "Ride: " + CardName(ride.PrintingId);
case CallIntent call: return "Call " + CardName(call.PrintingId) + " → " + SlotName(call.Slot);
case MoveIntent move: return "Move " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot);
case EnterBattleIntent _: return "В фазу боя";
case AttackIntent attack:
return "Атака " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot)
+ (attack.BoosterSlot >= 0 ? " + boost" : string.Empty);
case GuardIntent guard: return "Guard: " + guard.PrintingId;
case GuardIntent guard: return "Guard: " + CardName(guard.PrintingId);
case InterceptIntent intercept: return "Intercept: " + SlotName(intercept.Slot);
case PassGuardIntent _: return "Пропустить Guard";
case ResolveTriggerIntent trigger:

View File

@ -0,0 +1,166 @@
using System.Collections.Generic;
using UnityEngine;
namespace Vanguard.UI
{
/// <summary>
/// Процедурные карточные текстуры без внешних артов: цвет клана,
/// градиент, рубашка. Позже заменяется Addressables-артом.
/// </summary>
public static class CardArt
{
private static readonly Dictionary<string, Sprite> Cache =
new Dictionary<string, Sprite>();
private static Sprite _back;
public static Color ClanColor(string clan) => clan switch
{
"RoyalPaladin" => new Color(0.20f, 0.40f, 0.78f),
"OracleThinkTank" => new Color(0.72f, 0.58f, 0.22f),
"Kagero" => new Color(0.68f, 0.18f, 0.18f),
"Nubatama" => new Color(0.38f, 0.22f, 0.62f),
"Tachikaze" => new Color(0.22f, 0.56f, 0.34f),
"NovaGrappler" => new Color(0.78f, 0.40f, 0.16f),
"SpikeBrothers" => new Color(0.52f, 0.34f, 0.20f),
"DarkIrregulars" => new Color(0.30f, 0.18f, 0.38f),
"Granblue" => new Color(0.16f, 0.46f, 0.54f),
"Megacolony" => new Color(0.40f, 0.48f, 0.18f),
_ => new Color(0.28f, 0.34f, 0.46f),
};
public static Sprite ForClan(string clan)
{
var key = "clan:" + clan;
if (Cache.TryGetValue(key, out var sprite))
return sprite;
var color = ClanColor(clan);
sprite = Gradient(
Color.Lerp(color, Color.black, 0.55f),
Color.Lerp(color, Color.white, 0.18f),
accent: Color.Lerp(color, Color.white, 0.35f),
variant: 0);
Cache[key] = sprite;
return sprite;
}
public static Sprite ForCard(string nameId, string clan)
{
var key = "card:" + nameId;
if (Cache.TryGetValue(key, out var sprite))
return sprite;
var color = ClanColor(clan);
var hash = StableHash(nameId ?? string.Empty);
var accent = Color.HSVToRGB(
(hash % 360) / 360f,
0.62f,
0.98f);
sprite = Gradient(
Color.Lerp(color, Color.black, 0.58f),
Color.Lerp(color, Color.white, 0.16f),
accent,
(int)(hash % 5));
Cache[key] = sprite;
return sprite;
}
private static uint StableHash(string value)
{
var hash = 2166136261u;
foreach (var ch in value)
{
hash ^= ch;
hash *= 16777619u;
}
return hash;
}
public static Sprite Back
{
get
{
if (_back != null)
return _back;
_back = BackTexture();
return _back;
}
}
private static Sprite Gradient(Color bottom, Color top, Color accent, int variant)
{
const int width = 32;
const int height = 48;
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp,
};
var pixels = new Color[width * height];
for (var y = 0; y < height; y++)
{
var t = y / (float)(height - 1);
var row = Color.Lerp(bottom, top, t);
for (var x = 0; x < width; x++)
{
var stripe = ((x + y + variant * 3) % 11) < 2 ? 0.12f : 0f;
var glow = Mathf.Clamp01(1f - Mathf.Abs(x - width * 0.5f) / (width * 0.5f)) * 0.12f;
var cx = x - width * 0.5f;
var cy = y - height * 0.38f;
var radius = 6f + variant;
var sigil = Mathf.Abs(cx) + Mathf.Abs(cy) < radius ? 0.22f : 0f;
if (variant == 2 && Mathf.Abs(cx) < 2f && Mathf.Abs(cy) < 9f)
sigil += 0.18f;
pixels[y * width + x] = Color.Lerp(row, accent, Mathf.Clamp01(stripe + glow + sigil));
}
}
texture.SetPixels(pixels);
texture.Apply();
return Sprite.Create(
texture,
new Rect(0f, 0f, width, height),
new Vector2(0.5f, 0.5f),
100f);
}
private static Sprite BackTexture()
{
const int width = 32;
const int height = 48;
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Point,
wrapMode = TextureWrapMode.Clamp,
};
var pixels = new Color[width * height];
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var border = x == 0 || y == 0 || x == width - 1 || y == height - 1;
var diamond = (x + y) % 8 == 0 || (x - y + 64) % 8 == 0;
pixels[y * width + x] = border
? new Color(0.42f, 0.56f, 0.82f)
: diamond
? new Color(0.12f, 0.22f, 0.42f)
: new Color(0.05f, 0.09f, 0.17f);
}
}
texture.SetPixels(pixels);
texture.Apply();
return Sprite.Create(
texture,
new Rect(0f, 0f, width, height),
new Vector2(0.5f, 0.5f),
100f);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f6f3e2a1d9b4c7e8a5f0b3c2d1e6a94

View File

@ -69,6 +69,7 @@ namespace Vanguard.UI
new Dictionary<string, Text>(StringComparer.Ordinal);
private DeckDto _editing;
private int _dust;
private string _filterClan = "Все";
private string _filterTrigger = "Все";
private int _filterGrade = -1;
@ -503,7 +504,9 @@ namespace Vanguard.UI
return;
var meta = _selectedMeta;
_cardArt.color = ClanColor(meta.Clan);
_cardArt.sprite = CardArt.ForCard(meta.NameId, meta.Clan);
_cardArt.color = Color.white;
_cardArt.preserveAspect = false;
_cardArtName.text = meta.NameEn;
_cardTitle.text = meta.NameEn;
_cardSubtitle.text = (string.IsNullOrEmpty(meta.NameRu) ? string.Empty : meta.NameRu + " · ")
@ -519,18 +522,22 @@ namespace Vanguard.UI
? (string.IsNullOrWhiteSpace(meta.TextEn) ? "Текст способности отсутствует." : meta.TextEn)
: meta.TextRu;
_cardOwned.text = "В коллекции: ×" + (_selectedItem != null ? _selectedItem.Count : 0)
+ " · в колоде: " + UsedByName(meta.NameId);
+ " · в колоде: " + UsedByName(meta.NameId)
+ " · пыль: " + _dust;
var canChange = CanChange(meta.Rarity);
var craftCost = CraftCostFor(meta.Rarity);
_dismantleButton.interactable = canChange && !_busy
&& _selectedItem != null && _selectedItem.Count > 0;
_craftButton.interactable = canChange && !_busy;
_craftButton.interactable = canChange && !_busy && _dust >= craftCost;
SetButtonLabel(
_dismantleButton,
canChange ? "Разобрать (+" + DustFor(meta.Rarity) + " пыли)" : "Нельзя разобрать");
SetButtonLabel(
_craftButton,
canChange ? "Скрафтить (−" + CraftCostFor(meta.Rarity) + " пыли)" : "Нельзя скрафтить");
canChange
? "Скрафтить (−" + craftCost + " пыли)"
: "Нельзя скрафтить");
}
private static bool CanChange(string rarity) =>
@ -608,6 +615,7 @@ namespace Vanguard.UI
var collection = await _meta.GetCollectionAsync();
_collection.Clear();
_collection.AddRange(collection.Items ?? new List<CollectionItemDto>());
_dust = collection.Balances != null ? collection.Balances.Dust : 0;
RebuildCollectionGrids();
_selectedItem = _collection.Find(item => item.PrintingId == printingId);
if (_selectedItem == null)
@ -685,6 +693,7 @@ namespace Vanguard.UI
_collection.Clear();
_collection.AddRange(collection.Items ?? new List<CollectionItemDto>());
_dust = collection.Balances != null ? collection.Balances.Dust : 0;
_catalog.Clear();
foreach (var item in catalog)
_catalog[item.PrintingId] = item;
@ -695,8 +704,9 @@ namespace Vanguard.UI
BuildDeckList();
RebuildCollectionGrids();
_listStatus.text = _decks.Count == 0
? "Колод пока нет. Создайте первую. ПКМ по карте — подробности."
: "Карт в коллекции: " + _collection.Count + " · ПКМ по карте — подробности.";
? "Колод пока нет. Пыль: " + _dust + ". ПКМ по карте — подробности."
: "Карт в коллекции: " + _collection.Count + " · пыль " + _dust
+ " · ПКМ по карте — подробности.";
}
catch (ApiException exception)
{
@ -855,6 +865,10 @@ namespace Vanguard.UI
UiKit.AddVerticalLayout(cell, 6, 2);
AddRightClick(cell.gameObject, item.PrintingId, meta);
var frame = cell.gameObject.AddComponent<Outline>();
frame.effectColor = CardArt.ClanColor(meta.Clan);
frame.effectDistance = new Vector2(2f, -2f);
if (onAdd != null)
{
var button = cell.gameObject.AddComponent<Button>();
@ -862,8 +876,12 @@ namespace Vanguard.UI
button.onClick.AddListener(() => onAdd(item));
}
var art = UiKit.CreatePanel(cell, "Art", ClanColor(meta.Clan));
var art = UiKit.CreatePanel(cell, "Art", Color.white);
UiKit.SetPreferredHeight(art, 112f);
var artImage = art.GetComponent<Image>();
artImage.sprite = CardArt.ForCard(meta.NameId, meta.Clan);
artImage.color = Color.white;
artImage.preserveAspect = false;
var artName = UiKit.CreateText(
art, "ArtName", meta.NameEn, 12,
@ -943,8 +961,14 @@ namespace Vanguard.UI
AddRightClick(row.gameObject, printingId, meta);
var art = UiKit.CreatePanel(
row, "Art", ClanColor(meta != null ? meta.Clan : "None"));
row, "Art", Color.white);
SetFixedSize(art, 36f, 50f);
var artImage = art.GetComponent<Image>();
artImage.sprite = CardArt.ForCard(
meta != null ? meta.NameId : printingId,
meta != null ? meta.Clan : "None");
artImage.color = Color.white;
artImage.preserveAspect = false;
var artText = UiKit.CreateText(
art, "ArtText", meta != null ? "G" + meta.Grade : "?", 13,
@ -1344,21 +1368,6 @@ namespace Vanguard.UI
text.text = label;
}
private static Color ClanColor(string clan) => clan switch
{
"RoyalPaladin" => new Color(0.22f, 0.42f, 0.78f),
"OracleThinkTank" => new Color(0.72f, 0.58f, 0.22f),
"Kagero" => new Color(0.66f, 0.18f, 0.18f),
"Nubatama" => new Color(0.38f, 0.22f, 0.62f),
"Tachikaze" => new Color(0.22f, 0.56f, 0.34f),
"NovaGrappler" => new Color(0.78f, 0.40f, 0.16f),
"SpikeBrothers" => new Color(0.52f, 0.34f, 0.20f),
"DarkIrregulars" => new Color(0.28f, 0.20f, 0.36f),
"Granblue" => new Color(0.16f, 0.46f, 0.54f),
"Megacolony" => new Color(0.40f, 0.48f, 0.18f),
_ => new Color(0.25f, 0.30f, 0.40f),
};
private static void SetFixedSize(Component component, float width, float height)
{
var element = component.gameObject.GetComponent<LayoutElement>()

View File

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

View File

@ -741,3 +741,9 @@ Battle; Boot и общие сервисы остаются персистент
- Клиент: кнопка «С ботом» в редакторе колоды; `MetaService.StartPracticeAsync`,
переход в `Battle`. Серверный E2E-тест `PracticeBotE2ETests` проходит
полный матч против бота.
- Визуал: `CardArt` генерирует процедурные градиентные арты по клану и
рубашку; `BattleView` показывает рамки, имена, ранги, триггеры,
силу/крит, REST и скрытые карты; поля обоих игроков, рука и панель
действий. Коллекция использует те же арты.
- Проверки визуального блока: Unity EditMode 2/2, `SCENE_SMOKE success=True`,
live-практика с ботом на стенде.

View File

@ -55,9 +55,13 @@
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
создаёт casual-матч против случайного бота (`POST /api/game/practice`),
без истории, MMR и наград.
- Боевой экран: процедурные карты с арт-градиентом по клану, рамкой,
именем, рангом, триггером, силой/критом и рубашкой; поля Vanguard +
5 Арьергардов у обоих, рука, damage/soul/drop, REST/скрытые карты.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: арты/модели/анимации, локализация RU/EN, настройки, DOTween.
- Не готово: реальные арты/модели/анимации, локализация RU/EN, настройки,
DOTween.
## Запуск

View File

@ -52,8 +52,9 @@
## Ограничения и следующие шаги
- Пока нет 3D-ассетов: карты в gray-box показываются по `printingId`.
Арт и 3D-модели будут подключаться по `cardId` через Addressables.
- Пока нет 3D-ассетов: карты показываются процедурными градиентными
артами по клану и рубашкой (имя/ранг/триггер/сила). Арт и 3D-модели
будут подключаться по `cardId` через Addressables.
- Ближайший визуальный шаг — наполнить сцены моделями/артом и перевести
UI на prefab-экраны; логика и `SceneFlow` уже готовы.
- Боевые анимации, камера и 3D-карты — после стабилизации сетевого