diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index bd33a5d..5f82afb 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -268,7 +268,8 @@ **Визуальный каркас есть:** доска, рука, карточные рамки/арт-заглушки по клану, сила/крит, часы, все легальные интенты кнопками, сдача и -результат. Реальные арты и анимации — следующие. Финальный визуал — +результат. Реальные арты подключаются через `imageKey`, event-фидбек уже +есть; полноценная очередь анимаций — следующая. Финальный визуал — отдельная 3D-сцена `Battle`, а не текущий Canvas (`docs/client-visual-direction.md`). @@ -279,6 +280,8 @@ - [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»). - [x] Ввод: кнопки легальных интентов; клик по картам/зонам, подсветка целей, группировка действий и журнал боя. +- [x] Boost: выбор карты заднего ряда как поддержки перед атакой. +- [x] Event-фидбек: атака, guard, drive, урон, heal, retire, perfect guard. - [x] Отображение силы/крита из `MatchView`; модификаторы уже посчитаны сервером. - [ ] Очередь анимаций строго по событиям сервера: ride, call, attack, drive, damage, retire, boost, guard. - [ ] Скип/ускорение анимаций. diff --git a/client/Assets/Vanguard/Resources.meta b/client/Assets/Vanguard/Resources.meta new file mode 100644 index 0000000..33d01dd --- /dev/null +++ b/client/Assets/Vanguard/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1e9c4a7b2d5f4c8e9a3b6d0f7c2e8a51 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Resources/CardArt.meta b/client/Assets/Vanguard/Resources/CardArt.meta new file mode 100644 index 0000000..55e48f7 --- /dev/null +++ b/client/Assets/Vanguard/Resources/CardArt.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7a3d1f8c5e2b4a6d9c0f3b7e1a8d2c64 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Resources/CardArt/README.md b/client/Assets/Vanguard/Resources/CardArt/README.md new file mode 100644 index 0000000..b13a197 --- /dev/null +++ b/client/Assets/Vanguard/Resources/CardArt/README.md @@ -0,0 +1,10 @@ +# Card art drop-in + +Положите сюда изображения карт с именем `{imageKey}.png` (или `.jpg`). +`imageKey` приходит из `GET /api/cards` (например, `bt01_034`). + +Unity импортирует файл как Sprite; `CardImageService` подхватит его через +`Resources.Load("CardArt/{imageKey}")`. Если файла нет — используется +процедурный fallback из `CardArt`. + +Рекомендуемый размер: 512×720, Sprite (2D and UI), без анимации. diff --git a/client/Assets/Vanguard/Resources/CardArt/README.md.meta b/client/Assets/Vanguard/Resources/CardArt/README.md.meta new file mode 100644 index 0000000..fd9c40c --- /dev/null +++ b/client/Assets/Vanguard/Resources/CardArt/README.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b7e4c1f8a2d4f6e9c3a5b7d1e8f2a46 diff --git a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs index bce4b37..a7a0422 100644 --- a/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs +++ b/client/Assets/Vanguard/Runtime/Api/ApiContracts.cs @@ -312,6 +312,7 @@ namespace Vanguard.Api.Contracts [JsonProperty("hasIntercept")] public bool HasIntercept { get; set; } [JsonProperty("isParallel")] public bool IsParallel { get; set; } [JsonProperty("basePrintingId")] public string BasePrintingId { get; set; } + [JsonProperty("imageKey")] public string ImageKey { get; set; } [JsonProperty("textEn")] public string TextEn { get; set; } [JsonProperty("textRu")] public string TextRu { get; set; } } diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index f28e147..3402fbf 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -43,6 +43,11 @@ namespace Vanguard.UI private readonly RectTransform _result; private RectTransform _logContent; private Text _resultText; + private Text _feedback; + private Image _flash; + private float _feedbackTime; + private float _feedbackDuration; + private Color _feedbackColor; private readonly List _logLines = new List(); private GameConnection _connection; @@ -60,6 +65,7 @@ namespace Vanguard.UI private long _clockReceivedMs; private readonly List _pendingIntents = new List(); private readonly HashSet _highlightKeys = new HashSet(StringComparer.Ordinal); + private int _pendingBoosterSlot = -1; public BattleView(Transform parent, MetaService meta, Action onExit) { @@ -140,6 +146,23 @@ namespace Vanguard.UI _result = BuildResultPanel(_root); _result.gameObject.SetActive(false); + _flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent(); + _flash.raycastTarget = false; + _flash.gameObject.SetActive(false); + + _feedback = UiKit.CreateText( + _root, "BattleFeedback", string.Empty, 44, + Color.white, TextAnchor.MiddleCenter); + _feedback.fontStyle = FontStyle.Bold; + _feedback.raycastTarget = false; + var feedbackRect = _feedback.rectTransform; + feedbackRect.anchorMin = new Vector2(0.5f, 0.5f); + feedbackRect.anchorMax = new Vector2(0.5f, 0.5f); + feedbackRect.pivot = new Vector2(0.5f, 0.5f); + feedbackRect.sizeDelta = new Vector2(900f, 120f); + feedbackRect.anchoredPosition = new Vector2(0f, 130f); + _feedback.gameObject.SetActive(false); + _root.gameObject.SetActive(false); } @@ -155,9 +178,13 @@ namespace Vanguard.UI _result.gameObject.SetActive(false); _pendingIntents.Clear(); _highlightKeys.Clear(); + _pendingBoosterSlot = -1; _showAllIntents = false; _logLines.Clear(); RebuildLog(); + _feedbackTime = 0f; + _feedback.gameObject.SetActive(false); + _flash.gameObject.SetActive(false); _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; SetStatus("Подключаемся к матчу…", Muted); _root.gameObject.SetActive(true); @@ -180,6 +207,8 @@ namespace Vanguard.UI _root.gameObject.SetActive(false); _result.gameObject.SetActive(false); + _feedback.gameObject.SetActive(false); + _flash.gameObject.SetActive(false); _view = null; } @@ -192,6 +221,45 @@ namespace Vanguard.UI } UpdateClock(); + UpdateFeedback(); + } + + private void ShowFeedback(string text, Color color, float duration = 1.25f, bool flash = false) + { + _feedback.text = text; + _feedbackColor = color; + _feedbackDuration = duration; + _feedbackTime = duration; + _feedback.gameObject.SetActive(true); + _feedback.rectTransform.localScale = Vector3.one * 1.12f; + + if (flash) + { + _flash.gameObject.SetActive(true); + _flash.color = new Color(color.r, color.g, color.b, 0.22f); + } + } + + private void UpdateFeedback() + { + if (_feedbackTime <= 0f) + return; + + _feedbackTime -= Time.unscaledDeltaTime; + var t = Mathf.Clamp01(_feedbackTime / Mathf.Max(0.01f, _feedbackDuration)); + var alpha = Mathf.Clamp01(t * 1.8f); + _feedback.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha); + _feedback.rectTransform.localScale = Vector3.one * Mathf.Lerp(1f, 1.12f, t); + + if (_flash != null && _flash.gameObject.activeSelf) + _flash.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha * 0.22f); + + if (_feedbackTime > 0f) + return; + + _feedback.gameObject.SetActive(false); + if (_flash != null) + _flash.gameObject.SetActive(false); } private RectTransform BuildResultPanel(Transform parent) @@ -308,6 +376,7 @@ namespace Vanguard.UI _finished = _view.IsFinished; _pendingIntents.Clear(); _highlightKeys.Clear(); + _pendingBoosterSlot = -1; if (_view.Clock != null) { _clockServerMs = _view.Clock.ServerTimeMs; @@ -326,6 +395,8 @@ namespace Vanguard.UI var line = Describe(envelope.Event); if (!string.IsNullOrWhiteSpace(line)) _logLines.Add(line); + + ShowFeedbackFor(envelope.Event); } while (_logLines.Count > 40) @@ -357,6 +428,51 @@ namespace Vanguard.UI } } + private void ShowFeedbackFor(EventView view) + { + switch (view) + { + case AttackDeclaredView attack: + ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot) + + " → " + SlotName(attack.TargetSlot), + new Color(1f, 0.82f, 0.35f), 1.0f); + break; + case GuardAddedView guard: + ShowFeedback("GUARD +" + guard.Shield, + new Color(0.45f, 0.82f, 1f), 1.0f); + break; + case DriveCheckedView drive: + ShowFeedback("DRIVE " + TriggerShort(drive.Trigger.ToString()), + new Color(0.72f, 0.88f, 1f), 1.0f); + break; + case TriggerResolvedView trigger: + ShowFeedback("TRIGGER " + trigger.Trigger, + new Color(0.72f, 0.88f, 1f), 1.1f); + break; + case BattleResolvedView battle: + ShowFeedback(battle.Hit ? "HIT" : "BLOCK", + battle.Hit ? new Color(1f, 0.45f, 0.35f) : new Color(0.45f, 0.82f, 1f), + 0.9f); + break; + case DamageTakenView damage: + ShowFeedback("+" + damage.Amount + " DAMAGE", + new Color(1f, 0.30f, 0.26f), 1.2f, flash: true); + break; + case HealedView: + ShowFeedback("HEAL", + new Color(0.45f, 0.95f, 0.62f), 1.1f, flash: true); + break; + case UnitRetiredView: + ShowFeedback("RETIRE", + new Color(1f, 0.55f, 0.30f), 1.0f); + break; + case PerfectGuardView: + ShowFeedback("PERFECT GUARD", + new Color(0.60f, 0.82f, 1f), 1.1f); + break; + } + } + private string Describe(EventView view) { if (view == null) @@ -451,6 +567,7 @@ namespace Vanguard.UI _pendingIntents.Clear(); _highlightKeys.Clear(); + _pendingBoosterSlot = -1; _sending = true; SetStatus("Отправляем: " + Label(intent), Muted); @@ -840,13 +957,21 @@ namespace Vanguard.UI var art = UiKit.CreatePanel(panel, "Art", Color.white); UiKit.SetPreferredHeight(art, compact ? 46f : 58f); var artImage = art.GetComponent(); - 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) + { + CardImageService.Apply( + artImage, + meta != null ? meta.ImageKey : null, + CardArt.ForCard( + meta != null ? meta.NameId : card.PrintingId, + meta != null ? meta.Clan : "None")); + } + else + { + artImage.sprite = CardArt.Back; + artImage.color = Color.white; + artImage.preserveAspect = false; + } if (card.FaceUp) { @@ -936,9 +1061,38 @@ namespace Vanguard.UI if (_pendingIntents.Count > 0) { + // Клик по своему заднему ряду выбирает карту поддержки. + if (target.Player == _view.You && target.Slot is 0 or 1 or 2) + { + var boosted = _pendingIntents + .OfType() + .Where(attack => attack.BoosterSlot == target.Slot) + .ToList(); + + if (boosted.Count > 0) + { + _pendingBoosterSlot = target.Slot; + RebuildHighlights(); + RebuildIntents(_view); + SetStatus( + "Поддержка: " + CardName(target.PrintingId) + ". Выберите цель атаки.", + new Color(0.98f, 0.82f, 0.42f)); + return; + } + } + var matches = _pendingIntents .Where(intent => TryGetTarget(intent, out var candidate) && candidate.Matches(target)) - .OrderBy(intent => intent is AttackIntent attack && attack.BoosterSlot >= 0 ? 1 : 0) + .OrderBy(intent => + { + if (intent is not AttackIntent attack) + return 2; + + if (_pendingBoosterSlot >= 0) + return attack.BoosterSlot == _pendingBoosterSlot ? 0 : 3; + + return attack.BoosterSlot < 0 ? 0 : 1; + }) .ToList(); if (matches.Count > 0) @@ -988,6 +1142,7 @@ namespace Vanguard.UI { _pendingIntents.Clear(); _highlightKeys.Clear(); + _pendingBoosterSlot = -1; if (rebuild && _view != null) RebuildIntents(_view); } @@ -999,6 +1154,15 @@ namespace Vanguard.UI { if (TryGetTarget(intent, out var target)) _highlightKeys.Add(TargetKey(target.Player, target.Slot, target.PrintingId)); + + if (intent is AttackIntent attack && attack.BoosterSlot >= 0 && _view != null) + { + var booster = CardAt(_view.You, attack.BoosterSlot); + _highlightKeys.Add(TargetKey( + _view.You, + attack.BoosterSlot, + booster != null ? booster.PrintingId : null)); + } } } @@ -1138,7 +1302,11 @@ namespace Vanguard.UI var prompt = UiKit.CreateText( _intents, "TargetPrompt", - "Выберите цель на поле.\nНажмите «Отмена», чтобы снять выбор.", + _pendingBoosterSlot >= 0 + ? "Поддержка: " + CardName( + CardAt(_view.You, _pendingBoosterSlot)?.PrintingId ?? string.Empty) + + ".\nВыберите цель атаки или «Отмена»." + : "Выберите цель на поле.\nНажмите «Отмена», чтобы снять выбор.", 16, new Color(0.98f, 0.82f, 0.42f), TextAnchor.UpperLeft); diff --git a/client/Assets/Vanguard/Runtime/UI/CardImageService.cs b/client/Assets/Vanguard/Runtime/UI/CardImageService.cs new file mode 100644 index 0000000..b460dc1 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/CardImageService.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +namespace Vanguard.UI +{ + /// + /// Загрузка настоящих артов по imageKey. Соглашение: спрайт лежит в + /// Assets/Vanguard/Resources/CardArt/{imageKey}.png. Пока файла нет — + /// используется процедурный CardArt. Позже этот же вход можно + /// перевести на Addressables без изменения экранов. + /// + public static class CardImageService + { + private const string ResourceFolder = "CardArt/"; + private static readonly Dictionary Cache = + new Dictionary(); + + public static void Apply(Image target, string imageKey, Sprite fallback) + { + if (target == null) + return; + + target.sprite = fallback; + target.color = Color.white; + target.preserveAspect = false; + + if (string.IsNullOrWhiteSpace(imageKey)) + return; + + if (Cache.TryGetValue(imageKey, out var cached)) + { + if (cached != null) + target.sprite = cached; + return; + } + + var sprite = Resources.Load(ResourceFolder + imageKey); + Cache[imageKey] = sprite; + if (sprite != null) + target.sprite = sprite; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/CardImageService.cs.meta b/client/Assets/Vanguard/Runtime/UI/CardImageService.cs.meta new file mode 100644 index 0000000..d001169 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/CardImageService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d2c7a9e6b1f4e8da3c5b7f0e9a21d63 diff --git a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs index 8988ac8..35a00ae 100644 --- a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs +++ b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs @@ -504,9 +504,10 @@ namespace Vanguard.UI return; var meta = _selectedMeta; - _cardArt.sprite = CardArt.ForCard(meta.NameId, meta.Clan); - _cardArt.color = Color.white; - _cardArt.preserveAspect = false; + CardImageService.Apply( + _cardArt, + meta.ImageKey, + CardArt.ForCard(meta.NameId, meta.Clan)); _cardArtName.text = meta.NameEn; _cardTitle.text = meta.NameEn; _cardSubtitle.text = (string.IsNullOrEmpty(meta.NameRu) ? string.Empty : meta.NameRu + " · ") @@ -879,9 +880,10 @@ namespace Vanguard.UI var art = UiKit.CreatePanel(cell, "Art", Color.white); UiKit.SetPreferredHeight(art, 112f); var artImage = art.GetComponent(); - artImage.sprite = CardArt.ForCard(meta.NameId, meta.Clan); - artImage.color = Color.white; - artImage.preserveAspect = false; + CardImageService.Apply( + artImage, + meta.ImageKey, + CardArt.ForCard(meta.NameId, meta.Clan)); var artName = UiKit.CreateText( art, "ArtName", meta.NameEn, 12, @@ -964,11 +966,12 @@ namespace Vanguard.UI row, "Art", Color.white); SetFixedSize(art, 36f, 50f); var artImage = art.GetComponent(); - artImage.sprite = CardArt.ForCard( - meta != null ? meta.NameId : printingId, - meta != null ? meta.Clan : "None"); - artImage.color = Color.white; - artImage.preserveAspect = false; + CardImageService.Apply( + artImage, + meta != null ? meta.ImageKey : null, + CardArt.ForCard( + meta != null ? meta.NameId : printingId, + meta != null ? meta.Clan : "None")); var artText = UiKit.CreateText( art, "ArtText", meta != null ? "G" + meta.Grade : "?", 13, diff --git a/client/README.md b/client/README.md index ba253c7..d2cd442 100644 --- a/client/README.md +++ b/client/README.md @@ -68,6 +68,9 @@ http://localhost:5080 действия, журнал событий и подсказки. - Карты в бою и коллекции: процедурные арты по клану, рамка, имя, ранг, триггер, сила/крит, REST и рубашка для скрытых карт. +- Реальные арты: `CardImageService` ищет спрайт `Resources/CardArt/{imageKey}`; + `imageKey` приходит из `/api/cards`. Пока файлов нет — используется + процедурный fallback. - Экран входа и главный экран с балансами, MMR и переходами в разделы. ## Что дальше diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 43a6979..d79e78d 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -749,6 +749,11 @@ Battle; Boot и общие сервисы остаются персистент рубашку; `BattleView` показывает рамки, имена, ранги, триггеры, силу/крит, REST и скрытые карты; поля обоих игроков, рука и панель действий. Коллекция использует те же арты. +- Реальные арты: `imageKey` в `/api/cards`, `CardImageService` читает + `Resources/CardArt/{imageKey}`; без файлов работает процедурный fallback. +- Boost UI: клик по своему `SUP`-слоту выбирает поддержку, затем цель. +- Event-фидбек: всплывающие атака/guard/drive/урон/heal/retire, не + блокирует ввод. - Боевой UX: клик по карте/зоне, подсветка целей, отдельная группировка действий, журнал последних событий и подсказки выбора. - Поле: `R V R` / `R R R` у каждого игрока, зона урона слева, колода и diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 0e9319d..1807c6e 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -60,13 +60,17 @@ - Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона (+soul), справа колода и ниже дроп; у противника зеркально; между полями — зона защиты со щитом и проверками. +- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх + стола, не блокирует ввод. +- Реальные арты: `imageKey` из `/api/cards` + `CardImageService` ищет + `Resources/CardArt/{imageKey}`; без файлов — процедурный fallback. - Взаимодействие в бою: клик по картам/зонам, подсветка целей, группировка действий (карты → цели, глобальные кнопки отдельно), журнал последних событий. - Отдельная сцена `Collection` и `CollectionView` удалены; коллекция открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). -- Не готово: реальные арты/модели/анимации, локализация RU/EN, настройки, - DOTween. +- Не готово: файлы реальных артов (пайплайн готов), 3D-модели, + полноценная очередь анимаций, локализация RU/EN, настройки, DOTween. ## Запуск diff --git a/docs/server-api.md b/docs/server-api.md index 5c1d108..6f000a9 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -203,6 +203,7 @@ GET /api/cards [Bearer] "hasIntercept": true, "isParallel": false, "basePrintingId": null, + "imageKey": "bt01_034", "textEn": "[AUTO](VC/RC):…", "textRu": null } diff --git a/server/src/Vanguard.Server/Meta/MetaEndpoints.cs b/server/src/Vanguard.Server/Meta/MetaEndpoints.cs index 7814928..8f7ef3d 100644 --- a/server/src/Vanguard.Server/Meta/MetaEndpoints.cs +++ b/server/src/Vanguard.Server/Meta/MetaEndpoints.cs @@ -45,6 +45,7 @@ public sealed record CardCatalogItemDto( bool HasIntercept, bool IsParallel, string? BasePrintingId, + string? ImageKey, string? TextEn, string? TextRu); @@ -111,6 +112,7 @@ public static class MetaEndpoints card.HasIntercept, printing.IsParallel, printing.BasePrintingId, + printing.ImageKey, card.TextEn, card.TextRu); })