diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index f467850..bd33a5d 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -272,7 +272,8 @@ отдельная 3D-сцена `Battle`, а не текущий Canvas (`docs/client-visual-direction.md`). -- [x] Поле: Vanguard + 5 кругов Арьергарда, рука, damage/soul/drop-счётчики. +- [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева, + колода/дроп справа, зеркально у противника, зона защиты между полями. - [x] Процедурные карты: цвет/градиент клана, рубашка, имя, ранг, триггер, сила/крит; REST/скрытая карта. - [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»). diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 4f77b04..0e24fea 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -552,9 +552,9 @@ namespace Vanguard.UI var self = _view.Players.FirstOrDefault(player => player.Index == _view.You) ?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null); - AddFieldBlock(opponent, false); - AddCenterPanel(_view); - AddFieldBlock(self, true); + AddSideField(opponent, false); + AddGuardZone(_view); + AddSideField(self, true); AddHand(self); RebuildIntents(_view); UpdateClock(); @@ -563,7 +563,7 @@ namespace Vanguard.UI ShowResult(_view); } - private void AddFieldBlock(PlayerView player, bool self) + private void AddSideField(PlayerView player, bool self) { if (player == null) return; @@ -573,8 +573,8 @@ namespace Vanguard.UI self ? "SelfField" : "OpponentField", new Color(0.05f, 0.07f, 0.11f, 0.96f)); var blockElement = block.gameObject.AddComponent(); - blockElement.preferredHeight = self ? 214f : 204f; - UiKit.AddVerticalLayout(block, 7, 3); + blockElement.preferredHeight = 334f; + UiKit.AddVerticalLayout(block, 5, 3); var header = UiKit.CreateText( block, @@ -589,75 +589,160 @@ namespace Vanguard.UI self ? Color.white : new Color(0.94f, 0.82f, 0.70f), TextAnchor.MiddleLeft); 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 rowElement = row.gameObject.AddComponent(); - rowElement.flexibleHeight = 1f; - rowElement.minHeight = 166f; - UiKit.AddHorizontalLayout(row, 3, 7); + var body = UiKit.CreatePanel(block, "Body", new Color(0f, 0f, 0f, 0f)); + var bodyElement = body.gameObject.AddComponent(); + bodyElement.flexibleHeight = 1f; + bodyElement.minHeight = 294f; + UiKit.AddHorizontalLayout(body, 0, 8); - AddCardBox(row, player.Vanguard, "V", compact: false, player.Index, -1); - for (var i = 0; i < 5; i++) + if (self) { - var card = player.RearGuards != null && i < player.RearGuards.Length - ? player.RearGuards[i] - : null; - AddCardBox(row, card, "R" + (i + 1), compact: false, player.Index, i); + AddDamageColumn(body, player); + AddFieldGrid(body, player, self); + AddPileColumn(body, player); + } + 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 element = panel.gameObject.AddComponent(); - element.preferredHeight = 76f; - UiKit.AddVerticalLayout(panel, 8, 2); + var grid = UiKit.CreatePanel(parent, "FieldGrid", new Color(0f, 0f, 0f, 0f)); + var gridElement = grid.gameObject.AddComponent(); + gridElement.flexibleWidth = 1f; + gridElement.flexibleHeight = 1f; - 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), + var layout = grid.gameObject.AddComponent(); + layout.cellSize = new Vector2(140f, 140f); + layout.spacing = new Vector2(6f, 6f); + layout.padding = new RectOffset(4, 4, 4, 4); + layout.constraint = GridLayoutGroup.Constraint.FixedColumnCount; + layout.constraintCount = 3; + layout.childAlignment = TextAnchor.MiddleCenter; + layout.startCorner = GridLayoutGroup.Corner.UpperLeft; + layout.startAxis = GridLayoutGroup.Axis.Horizontal; + + // Первый ряд: 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(); + 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(); + 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(); + element.preferredHeight = height; + element.flexibleWidth = 1f; + + var outline = pile.gameObject.AddComponent(); + 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(); + element.preferredHeight = 72f; + UiKit.AddHorizontalLayout(guard, 14, 12); + + var label = UiKit.CreateText( + guard, + "Label", + "ЗОНА ЗАЩИТЫ", + 18, + new Color(0.82f, 0.88f, 1f), TextAnchor.MiddleLeft); - main.fontStyle = FontStyle.Bold; - UiKit.SetPreferredHeight(main, 26f); + label.fontStyle = FontStyle.Bold; + UiKit.SetPreferredHeight(label, 32f, 0f); - var details = new List(); - 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 shield = UiKit.CreateText( + guard, + "Shield", + "Щит: " + (view.Battle != null ? view.Battle.GuardShield.ToString() : "—"), + 18, + new Color(0.55f, 0.86f, 1f), + TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(shield, 32f, 0f); - var detail = UiKit.CreateText( - panel, "Detail", string.Join(" · ", details), 14, - Muted, TextAnchor.MiddleLeft); - detail.horizontalOverflow = HorizontalWrapMode.Wrap; - UiKit.SetPreferredHeight(detail, 28f); + var guardian = UiKit.CreateText( + guard, + "Guardian", + "Проверок: " + (view.Battle != null ? view.Battle.ChecksRemaining : 0) + + " · защита между полями", + 14, + Muted, + TextAnchor.MiddleRight); + UiKit.SetPreferredHeight(guardian, 32f); } private void AddHand(PlayerView self) { var hand = UiKit.CreatePanel(_board, "HandBlock", new Color(0.05f, 0.07f, 0.11f, 0.96f)); var handElement = hand.gameObject.AddComponent(); - handElement.preferredHeight = 188f; + handElement.preferredHeight = 164f; UiKit.AddVerticalLayout(hand, 6, 3); var header = UiKit.CreateText( @@ -670,7 +755,7 @@ namespace Vanguard.UI var scroll = UiKit.CreateHorizontalScrollView(hand, "HandScroll", out var content, 6f); var scrollElement = scroll.gameObject.AddComponent(); scrollElement.flexibleHeight = 1f; - scrollElement.minHeight = 130f; + scrollElement.minHeight = 124f; 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.05f, 0.08f, 0.14f, 1f)); var element = panel.gameObject.AddComponent(); - element.preferredWidth = compact ? 106f : 124f; - element.preferredHeight = compact ? 142f : 162f; + element.preferredWidth = compact ? 104f : 140f; + element.preferredHeight = compact ? 124f : 140f; element.flexibleWidth = 0f; var highlighted = _highlightKeys.Contains( @@ -740,21 +825,20 @@ namespace Vanguard.UI if (card == null) { - var emptyArt = UiKit.CreatePanel(panel, "EmptyArt", new Color(0.04f, 0.06f, 0.09f, 1f)); - UiKit.SetPreferredHeight(emptyArt, compact ? 48f : 64f); + var panelImage = panel.GetComponent(); + panelImage.sprite = CardArt.Zone; + panelImage.color = Color.white; + panelImage.preserveAspect = true; + 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); + panel, "EmptyLabel", "Пусто", 12, + new Color(1f, 1f, 1f, 0.40f), TextAnchor.MiddleCenter); + UiKit.SetPreferredHeight(empty, compact ? 100f : 116f); return; } var art = UiKit.CreatePanel(panel, "Art", Color.white); - UiKit.SetPreferredHeight(art, compact ? 48f : 64f); + UiKit.SetPreferredHeight(art, compact ? 46f : 58f); var artImage = art.GetComponent(); artImage.sprite = card.FaceUp ? CardArt.ForCard( @@ -815,7 +899,7 @@ namespace Vanguard.UI TextAnchor.UpperLeft); name.horizontalOverflow = HorizontalWrapMode.Wrap; name.fontStyle = FontStyle.Bold; - UiKit.SetPreferredHeight(name, compact ? 26f : 28f); + UiKit.SetPreferredHeight(name, compact ? 22f : 24f); var stats = UiKit.CreateText( panel, "Stats", @@ -823,12 +907,12 @@ namespace Vanguard.UI ? "P " + card.Power + " · C " + card.Critical + (meta != null ? " · " + TriggerShort(meta.Trigger) : string.Empty) : "СКРЫТА", - 11, + 10, 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); + UiKit.SetPreferredHeight(stats, 16f); } private static string TriggerShort(string trigger) => trigger switch diff --git a/client/Assets/Vanguard/Runtime/UI/CardArt.cs b/client/Assets/Vanguard/Runtime/UI/CardArt.cs index 3f93b88..2efac2e 100644 --- a/client/Assets/Vanguard/Runtime/UI/CardArt.cs +++ b/client/Assets/Vanguard/Runtime/UI/CardArt.cs @@ -12,6 +12,7 @@ namespace Vanguard.UI private static readonly Dictionary Cache = new Dictionary(); private static Sprite _back; + private static Sprite _zone; 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) { const int width = 32; diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index d500314..0099fff 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -747,5 +747,7 @@ Battle; Boot и общие сервисы остаются персистент действий. Коллекция использует те же арты. - Боевой UX: клик по карте/зоне, подсветка целей, отдельная группировка действий, журнал последних событий и подсказки выбора. +- Поле: `R V R` / `R R R` у каждого игрока, зона урона слева, колода и + дроп справа, зеркально у противника, зона защиты между полями. - Проверки визуального блока: Unity EditMode 2/2, `SCENE_SMOKE success=True`, live-практика с ботом на стенде. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index d5ff02e..ff884f3 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -56,8 +56,10 @@ создаёт casual-матч против случайного бота (`POST /api/game/practice`), без истории, MMR и наград. - Боевой экран: процедурные карты с арт-градиентом по клану, рамкой, - именем, рангом, триггером, силой/критом и рубашкой; поля Vanguard + - 5 Арьергардов у обоих, рука, damage/soul/drop, REST/скрытые карты. + именем, рангом, триггером, силой/критом и рубашкой. +- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона + (+soul), справа колода и ниже дроп; у противника зеркально; между + полями — зона защиты со щитом и проверками. - Взаимодействие в бою: клик по картам/зонам, подсветка целей, группировка действий (карты → цели, глобальные кнопки отдельно), журнал последних событий.