Add procedural card graphics and battle board
CI / test (push) Successful in 5m15s
Details
CI / test (push) Successful in 5m15s
Details
This commit is contained in:
parent
e0deecacd5
commit
0e528586b4
|
|
@ -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`; модификаторы уже посчитаны сервером.
|
||||
|
|
|
|||
|
|
@ -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-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с процедурными картами |
|
||||
| Лаунчер | не начато |
|
||||
|
||||
## Требования
|
||||
|
|
|
|||
|
|
@ -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,166 +470,269 @@ 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
|
||||
(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),
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
var stateText = (card.Rested ? "REST " : string.Empty)
|
||||
+ (card.FaceUp ? string.Empty : "FACE-DOWN");
|
||||
if (!string.IsNullOrEmpty(stateText))
|
||||
private static string TriggerShort(string trigger) => trigger switch
|
||||
{
|
||||
var state = UiKit.CreateText(panel, "State", stateText.Trim(), 12, new Color(1f, 0.76f, 0.38f));
|
||||
UiKit.SetPreferredHeight(state, 18f);
|
||||
}
|
||||
}
|
||||
"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)
|
||||
{
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8f6f3e2a1d9b4c7e8a5f0b3c2d1e6a94
|
||||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ http://localhost:5080
|
|||
интенты, ответы на ping, закрытие и переподключение.
|
||||
- Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов,
|
||||
сдача с подтверждением и экран результата.
|
||||
- Карты в бою и коллекции: процедурные арты по клану, рамка, имя, ранг,
|
||||
триггер, сила/крит, REST и рубашка для скрытых карт.
|
||||
- Экран входа и главный экран с балансами, MMR и переходами в разделы.
|
||||
|
||||
## Что дальше
|
||||
|
|
|
|||
|
|
@ -741,3 +741,9 @@ Battle; Boot и общие сервисы остаются персистент
|
|||
- Клиент: кнопка «С ботом» в редакторе колоды; `MetaService.StartPracticeAsync`,
|
||||
переход в `Battle`. Серверный E2E-тест `PracticeBotE2ETests` проходит
|
||||
полный матч против бота.
|
||||
- Визуал: `CardArt` генерирует процедурные градиентные арты по клану и
|
||||
рубашку; `BattleView` показывает рамки, имена, ранги, триггеры,
|
||||
силу/крит, REST и скрытые карты; поля обоих игроков, рука и панель
|
||||
действий. Коллекция использует те же арты.
|
||||
- Проверки визуального блока: Unity EditMode 2/2, `SCENE_SMOKE success=True`,
|
||||
live-практика с ботом на стенде.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
## Запуск
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,9 @@
|
|||
|
||||
## Ограничения и следующие шаги
|
||||
|
||||
- Пока нет 3D-ассетов: карты в gray-box показываются по `printingId`.
|
||||
Арт и 3D-модели будут подключаться по `cardId` через Addressables.
|
||||
- Пока нет 3D-ассетов: карты показываются процедурными градиентными
|
||||
артами по клану и рубашкой (имя/ранг/триггер/сила). Арт и 3D-модели
|
||||
будут подключаться по `cardId` через Addressables.
|
||||
- Ближайший визуальный шаг — наполнить сцены моделями/артом и перевести
|
||||
UI на prefab-экраны; логика и `SceneFlow` уже готовы.
|
||||
- Боевые анимации, камера и 3D-карты — после стабилизации сетевого
|
||||
|
|
|
|||
Loading…
Reference in New Issue