From 0e2907803ac08ff7a3f7adad00948c43705272b0 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Thu, 22 Oct 2026 01:26:14 +0500 Subject: [PATCH] Animate card movement and add RU/EN localization --- IMPLEMENTATION_CHECKLIST.md | 21 +- README.md | 10 +- .../Vanguard/Runtime/App/VanguardApp.cs | 2 + .../Assets/Vanguard/Runtime/UI/BattleView.cs | 318 ++++++++++- .../Vanguard/Runtime/UI/DeckBuilderView.cs | 29 +- .../Vanguard/Runtime/UI/Localization.cs | 505 ++++++++++++++++++ .../Vanguard/Runtime/UI/Localization.cs.meta | 12 + .../Assets/Vanguard/Runtime/UI/LoginView.cs | 8 + client/Assets/Vanguard/Runtime/UI/MetaView.cs | 4 + .../Vanguard/Runtime/UI/OnboardingView.cs | 1 + client/Assets/Vanguard/Runtime/UI/UiKit.cs | 35 ++ .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 34 ++ client/README.md | 22 +- docs/HANDOFF.md | 34 +- docs/SESSION-STATE.md | 39 +- 15 files changed, 1013 insertions(+), 61 deletions(-) create mode 100644 client/Assets/Vanguard/Runtime/UI/Localization.cs create mode 100644 client/Assets/Vanguard/Runtime/UI/Localization.cs.meta diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 8a356bd..3eb2279 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -257,8 +257,8 @@ - [x] История матчей и реплей: список результатов, события и кадры. - [x] Тренировка с ботом: серверный casual-матч, случайный легальный бот, без истории/MMR/наград. -- [ ] Локализация RU/EN, переключение на лету (пакет подключён). -- [ ] Настройки: язык, звук, FPS, мьют. +- [x] Локализация RU/EN, переключение на лету; выбор сохраняется. +- [ ] Настройки: звук, FPS, мьют (язык уже есть). **Готово, когда:** все мета-экраны работают против реального сервера, язык переключается без перезапуска. @@ -266,17 +266,17 @@ ## 8. Клиент: боевая сцена `[20–30 дней]` -**Визуальный каркас есть:** доска, рука, карточные рамки/арт-заглушки -по клану, сила/крит, часы, все легальные интенты кнопками, сдача и -результат. Реальные арты подключаются через `imageKey`, event-фидбек уже -есть; полноценная очередь анимаций — следующая. Финальный визуал — -отдельная 3D-сцена `Battle`, а не текущий Canvas +**Визуальный каркас есть:** доска, рука, реальные арты (124/124) с +fallback по клану, сила/крит, часы, все легальные интенты кнопками, сдача +и результат. Event-фидбек и перемещения карт работают; отдельные +drive/damage-анимации и звук — следующие. Финальный визуал — отдельная +3D-сцена `Battle`, а не текущий Canvas (`docs/client-visual-direction.md`). - [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева, колода/дроп справа, зеркально у противника, зона защиты между полями. -- [x] Процедурные карты: цвет/градиент клана, рубашка, имя, ранг, триггер, - сила/крит; REST/скрытая карта. +- [x] Карты: реальный арт из `imageKey`, рамка клана, рубашка, имя, ранг, + триггер, сила/крит, REST/скрытая карта; процедурный fallback. - [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»). - [x] Ввод: кнопки легальных интентов; клик по картам/зонам, подсветка целей, группировка действий, выбор силы/эффекта триггера @@ -285,7 +285,8 @@ - [x] Event-фидбек: атака, guard, drive, урон, heal, retire, perfect guard. - [x] Отображение силы/крита из `MatchView`; модификаторы уже посчитаны сервером. - [x] Очередь event-анимаций и кнопка «Пропустить анимации». -- [ ] Полноценные move/attack/drive/damage-анимации карт и стола. +- [x] Перемещения карт call/ride и уход в дроп, пульс атаки. +- [ ] Отдельные drive/damage-анимации карт и стола, звук. - [x] Таймер хода, индикатор реконнекта, кнопка сдачи (базово). - [x] Экран результата: победа/поражение; Gold/XP и «ещё матч» — дальше. - [x] Догон состояния по `resync`/snapshot после реконнекта (без переигрывания анимаций). diff --git a/README.md b/README.md index 616fa35..af322e6 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | EF-миграции | готово (`InitialCreate`) | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | -| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с процедурными картами | +| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с реальными артами, анимациями и RU/EN | | Лаунчер | не начато | ## Требования @@ -143,6 +143,14 @@ dotnet run --project server/src/Vanguard.Cli -- translations check токены, которых нет в справочнике. Карты без эффекта считаются готовыми сразу — им переводить нечего. +## Язык клиента + +Интерфейс переключается между **RU** и **EN** кнопкой в Login и Lobby; +выбор сохраняется в `PlayerPrefs`. Переводы UI живут в +`client/Assets/Vanguard/Runtime/UI/Localization.cs`, а имена и тексты карт +берутся из пары `nameRu/nameEn` и `textRu/textEn` каталога `/api/cards`. +Переключение применяется на лету, без перезагрузки сцены. + ## Документация - `plan.md` — исходный дизайн-документ. diff --git a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs index f3e6dfe..39f71a8 100644 --- a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs +++ b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs @@ -5,6 +5,7 @@ using Vanguard.Api; using Vanguard.Auth; using Vanguard.Meta; using Vanguard.State; +using Vanguard.UI; namespace Vanguard.App { @@ -29,6 +30,7 @@ namespace Vanguard.App Instance = this; DontDestroyOnLoad(gameObject); Application.targetFrameRate = 60; + Loc.Initialize(); var config = new AppConfig(); config.Load(); diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 569a7e0..fc92be9 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -57,6 +57,7 @@ namespace Vanguard.UI private CancellationTokenSource _cts; private MatchConnectionInfo _info; private MatchView _view; + private MatchView _previousView; private bool _connecting; private bool _sending; private bool _snapshotPending; @@ -73,6 +74,15 @@ namespace Vanguard.UI private readonly Dictionary _cardPanels = new Dictionary(StringComparer.Ordinal); private readonly List _cardAnimations = new List(); + private readonly List _pendingEntries = new List(); + private readonly List _ghostAnimations = new List(); + private readonly Dictionary _cardPrintings = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _cardFaceUp = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _cardAnchors = + new Dictionary(StringComparer.Ordinal); + private RectTransform _fxLayer; public BattleView(Transform parent, MetaService meta, Action onExit) { @@ -95,6 +105,7 @@ namespace Vanguard.UI _topInfo = UiKit.CreateText( top, "Match", string.Empty, 17, Muted, TextAnchor.MiddleLeft); + UiKit.MakeLiteral(_topInfo); UiKit.SetPreferredHeight(_topInfo, 38f); _clock = UiKit.CreateText( @@ -158,6 +169,9 @@ namespace Vanguard.UI _result = BuildResultPanel(_root); _result.gameObject.SetActive(false); + _fxLayer = UiKit.CreatePanel(_root, "BattleFx", Color.clear); + _fxLayer.GetComponent().raycastTarget = false; + _flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent(); _flash.raycastTarget = false; _flash.gameObject.SetActive(false); @@ -182,6 +196,7 @@ namespace Vanguard.UI { _info = info; _view = null; + _previousView = null; _finished = false; _confirmConcede = false; _concedeLabel.text = "Сдаться"; @@ -201,6 +216,8 @@ namespace Vanguard.UI _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); + ClearGhosts(); + _cardAnchors.Clear(); _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; SetStatus("Подключаемся к матчу…", Muted); _root.gameObject.SetActive(true); @@ -315,21 +332,185 @@ namespace Vanguard.UI var scale = Mathf.Lerp(animation.StartScale, 1f, eased); animation.Target.localScale = new Vector3(scale, scale, 1f); + if (animation.Move) + { + animation.Target.anchoredPosition = Vector2.Lerp( + animation.StartPosition, + animation.Destination, + eased); + } + if (t >= 1f) { animation.Target.localScale = Vector3.one; + if (animation.Move) + animation.Target.anchoredPosition = animation.Destination; _cardAnimations.RemoveAt(i); } } + + UpdateGhostAnimations(); } - private void AnimateCardIn(RectTransform target) + private void UpdateGhostAnimations() + { + for (var i = _ghostAnimations.Count - 1; i >= 0; i--) + { + var ghost = _ghostAnimations[i]; + if (ghost.Target == null) + { + _ghostAnimations.RemoveAt(i); + continue; + } + + ghost.Time += Time.unscaledDeltaTime; + var t = Mathf.Clamp01(ghost.Time / ghost.Duration); + var eased = 1f - Mathf.Pow(1f - t, 2f); + ghost.Target.position = Vector3.Lerp(ghost.Start, ghost.End, eased); + ghost.Target.localScale = Vector3.one * Mathf.Lerp(1f, 0.82f, eased); + if (ghost.Group != null) + ghost.Group.alpha = 1f - eased; + + if (t < 1f) + continue; + + if (ghost.Target != null) + UnityEngine.Object.Destroy(ghost.Target.gameObject); + _ghostAnimations.RemoveAt(i); + } + } + + private void ClearGhosts() + { + foreach (var ghost in _ghostAnimations) + { + if (ghost.Target != null) + UnityEngine.Object.Destroy(ghost.Target.gameObject); + } + + _ghostAnimations.Clear(); + } + + private void CaptureCardAnchors() + { + foreach (var pair in _cardPrintings) + { + if (!_cardPanels.TryGetValue(pair.Key, out var panel) || panel == null) + continue; + + _cardFaceUp.TryGetValue(pair.Key, out var faceUp); + _cardAnchors[pair.Key] = new CardAnchor( + panel.position, + panel.rect.size, + pair.Value, + faceUp); + } + } + + private void SpawnRetireGhosts(Dictionary oldAnchors) + { + if (oldAnchors.Count == 0 || _fxLayer == null) + return; + + foreach (var pair in oldAnchors) + { + if (_cardAnchors.ContainsKey(pair.Key)) + continue; + + // Если карта просто переехала в другой слот, не считаем это уходом в дроп. + if (_cardAnchors.Values.Any(item => item.PrintingId == pair.Value.PrintingId)) + continue; + + CreateRetireGhost(pair.Key, pair.Value); + } + } + + private void CreateRetireGhost(string key, CardAnchor anchor) + { + var panel = UiKit.CreatePanel(_fxLayer, "Retired_" + key, Color.white); + panel.anchorMin = new Vector2(0.5f, 0.5f); + panel.anchorMax = new Vector2(0.5f, 0.5f); + panel.pivot = new Vector2(0.5f, 0.5f); + panel.sizeDelta = anchor.Size; + panel.position = anchor.World; + + var image = panel.GetComponent(); + image.raycastTarget = false; + var meta = _catalog.TryGetValue(anchor.PrintingId, out var found) ? found : null; + if (anchor.FaceUp) + { + CardImageService.Apply( + image, + meta != null ? meta.ImageKey : null, + CardArt.ForCard( + meta != null ? meta.NameId : anchor.PrintingId, + meta != null ? meta.Clan : "None")); + } + else + { + image.sprite = CardArt.Back; + image.color = Color.white; + } + + var outline = panel.gameObject.AddComponent(); + outline.effectColor = new Color(1f, 0.35f, 0.28f, 0.9f); + outline.effectDistance = new Vector2(2f, -2f); + + var group = panel.gameObject.AddComponent(); + group.blocksRaycasts = false; + group.interactable = false; + + var player = 0; + var split = key.IndexOf(':'); + if (split > 0) + int.TryParse(key.Substring(0, split), out player); + + var direction = _view != null && player == _view.You ? -1f : 1f; + _ghostAnimations.Add(new GhostAnimation( + panel, + group, + anchor.World, + anchor.World + new Vector3(0f, direction * 190f, 0f), + 0.36f)); + } + + private void FinalizeCardEntries() + { + // Сначала даём layout-группам посчитать конечные позиции карт. + Canvas.ForceUpdateCanvases(); + LayoutRebuilder.ForceRebuildLayoutImmediate(_board); + CaptureCardAnchors(); + + if (_pendingEntries.Count == 0) + return; + + foreach (var entry in _pendingEntries) + { + if (entry.Target == null) + continue; + + var destination = entry.Target.anchoredPosition; + var start = destination + entry.Offset; + entry.Target.anchoredPosition = start; + _cardAnimations.Add(new CardAnimation( + entry.Target, + 0.24f, + entry.StartScale, + start, + destination, + move: true)); + } + + _pendingEntries.Clear(); + } + + private void AnimateCardIn(RectTransform target, Vector2 offset) { if (target == null) return; target.localScale = Vector3.one * 0.86f; - _cardAnimations.Add(new CardAnimation(target, 0.18f, 0.86f)); + _pendingEntries.Add(new CardEntry(target, offset, 0.86f)); } private void PulseCard(int player, int slot, float startScale) @@ -337,30 +518,115 @@ namespace Vanguard.UI if (!_cardPanels.TryGetValue(CardKey(player, slot), out var target) || target == null) return; - for (var i = _cardAnimations.Count - 1; i >= 0; i--) + target.localScale = Vector3.one * startScale; + _cardAnimations.Add(new CardAnimation( + target, 0.28f, startScale, target.anchoredPosition, target.anchoredPosition, move: false)); + } + + private bool ShouldAnimateCard(int player, int slot, string printingId) + { + if (_previousView == null) + return true; + + var side = _previousView.Players.FirstOrDefault(item => item.Index == player); + if (side == null) + return true; + + if (slot == -2) { - if (_cardAnimations[i].Target == target) - _cardAnimations.RemoveAt(i); + return side.Hand == null + || side.Hand.All(item => item.PrintingId != printingId); } - target.localScale = Vector3.one * startScale; - _cardAnimations.Add(new CardAnimation(target, 0.28f, startScale)); + if (slot == -1) + return side.Vanguard == null || side.Vanguard.PrintingId != printingId; + + return side.RearGuards == null + || slot >= side.RearGuards.Length + || side.RearGuards[slot] == null + || side.RearGuards[slot].PrintingId != printingId; } private static string CardKey(int player, int slot) => player + ":" + slot; + private readonly struct CardAnchor + { + public CardAnchor(Vector3 world, Vector2 size, string printingId, bool faceUp) + { + World = world; + Size = size; + PrintingId = printingId; + FaceUp = faceUp; + } + + public Vector3 World { get; } + public Vector2 Size { get; } + public string PrintingId { get; } + public bool FaceUp { get; } + } + + private sealed class GhostAnimation + { + public GhostAnimation( + RectTransform target, + CanvasGroup group, + Vector3 start, + Vector3 end, + float duration) + { + Target = target; + Group = group; + Start = start; + End = end; + Duration = duration; + } + + public RectTransform Target { get; } + public CanvasGroup Group { get; } + public Vector3 Start { get; } + public Vector3 End { get; } + public float Duration { get; } + public float Time { get; set; } + } + + private sealed class CardEntry + { + public CardEntry(RectTransform target, Vector2 offset, float startScale) + { + Target = target; + Offset = offset; + StartScale = startScale; + } + + public RectTransform Target { get; } + public Vector2 Offset { get; } + public float StartScale { get; } + } + private sealed class CardAnimation { - public CardAnimation(RectTransform target, float duration, float startScale) + public CardAnimation( + RectTransform target, + float duration, + float startScale, + Vector2 startPosition, + Vector2 destination, + bool move) { Target = target; Duration = duration; StartScale = startScale; + StartPosition = startPosition; + Destination = destination; + Move = move; } public RectTransform Target { get; } public float Duration { get; } public float StartScale { get; } + public Vector2 StartPosition { get; } + public Vector2 Destination { get; } + public bool Move { get; } public float Time { get; set; } } @@ -500,6 +766,7 @@ namespace Vanguard.UI if (!_root.gameObject.activeSelf || snapshot?.View == null) return; + _previousView = _view; _view = snapshot.View; _finished = _view.IsFinished; _pendingIntents.Clear(); @@ -792,9 +1059,14 @@ namespace Vanguard.UI if (_view == null) return; + var oldAnchors = new Dictionary(_cardAnchors, StringComparer.Ordinal); ClearChildren(_board); _cardPanels.Clear(); + _cardPrintings.Clear(); + _cardFaceUp.Clear(); + _cardAnchors.Clear(); _cardAnimations.Clear(); + _pendingEntries.Clear(); var opponentIndex = _view.You == 0 ? 1 : 0; var opponent = _view.Players.FirstOrDefault(player => player.Index == opponentIndex) ?? (_view.Players.Count > opponentIndex ? _view.Players[opponentIndex] : null); @@ -805,6 +1077,8 @@ namespace Vanguard.UI AddGuardZone(_view); AddSideField(self, true); AddHand(self); + FinalizeCardEntries(); + SpawnRetireGhosts(oldAnchors); if (_view.Battle != null) { @@ -1073,9 +1347,23 @@ namespace Vanguard.UI ? new Vector2(3f, -3f) : new Vector2(2f, -2f); - _cardPanels[CardKey(playerIndex, slotIndex)] = panel; - if (card != null) - AnimateCardIn(panel); + var key = CardKey(playerIndex, slotIndex); + _cardPanels[key] = panel; + if (card != null && slotIndex >= -1) + { + _cardPrintings[key] = card.PrintingId; + _cardFaceUp[key] = card.FaceUp; + } + + if (card != null && ShouldAnimateCard(playerIndex, slotIndex, card.PrintingId)) + { + var offset = slotIndex == -2 + ? new Vector2(0f, -120f) + : playerIndex == _view.You + ? new Vector2(0f, -250f) + : new Vector2(0f, 250f); + AnimateCardIn(panel, offset); + } UiKit.AddVerticalLayout(panel, 4, 2); var slotText = UiKit.CreateText( @@ -1119,8 +1407,9 @@ namespace Vanguard.UI if (card.FaceUp) { var artName = UiKit.CreateText( - art, "ArtName", meta != null ? meta.NameEn : card.PrintingId, 10, + art, "ArtName", meta != null ? Loc.CardName(meta) : card.PrintingId, 10, new Color(1f, 1f, 1f, 0.94f), TextAnchor.MiddleCenter); + UiKit.MakeLiteral(artName); UiKit.Stretch(artName.rectTransform); artName.rectTransform.offsetMin = new Vector2(3f, 3f); artName.rectTransform.offsetMax = new Vector2(-3f, -2f); @@ -1161,10 +1450,11 @@ namespace Vanguard.UI var name = UiKit.CreateText( panel, "Name", - meta != null ? meta.NameEn : card.PrintingId, + meta != null ? Loc.CardName(meta) : card.PrintingId, 10, card.FaceUp ? Color.white : Muted, TextAnchor.UpperLeft); + UiKit.MakeLiteral(name); name.horizontalOverflow = HorizontalWrapMode.Wrap; name.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(name, compact ? 22f : 24f); @@ -1193,7 +1483,7 @@ namespace Vanguard.UI }; private string CardName(string printingId) => - _catalog.TryGetValue(printingId, out var meta) ? meta.NameEn : printingId; + _catalog.TryGetValue(printingId, out var meta) ? Loc.CardName(meta) : printingId; private void OnBoardTargetClicked(BoardTarget target) { diff --git a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs index 35a00ae..4fcb60d 100644 --- a/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs +++ b/client/Assets/Vanguard/Runtime/UI/DeckBuilderView.cs @@ -399,6 +399,7 @@ namespace Vanguard.UI _cardArtName = UiKit.CreateText( art, "ArtName", string.Empty, 18, new Color(1f, 1f, 1f, 0.92f), TextAnchor.MiddleCenter); + UiKit.MakeLiteral(_cardArtName); UiKit.Stretch(_cardArtName.rectTransform); _cardArtName.rectTransform.offsetMin = new Vector2(14f, 16f); _cardArtName.rectTransform.offsetMax = new Vector2(-14f, -16f); @@ -412,6 +413,7 @@ namespace Vanguard.UI _cardTitle = UiKit.CreateText( info, "Title", string.Empty, 30, Color.white, TextAnchor.MiddleLeft); + UiKit.MakeLiteral(_cardTitle); _cardTitle.fontStyle = FontStyle.Bold; _cardTitle.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(_cardTitle, 44f); @@ -419,6 +421,7 @@ namespace Vanguard.UI _cardSubtitle = UiKit.CreateText( info, "Subtitle", string.Empty, 16, new Color(0.62f, 0.70f, 0.84f), TextAnchor.MiddleLeft); + UiKit.MakeLiteral(_cardSubtitle); UiKit.SetPreferredHeight(_cardSubtitle, 26f); _cardStats = UiKit.CreateText( @@ -435,6 +438,7 @@ namespace Vanguard.UI _cardAbilities = UiKit.CreateText( info, "Abilities", string.Empty, 15, new Color(0.82f, 0.86f, 0.96f), TextAnchor.UpperLeft); + UiKit.MakeLiteral(_cardAbilities); _cardAbilities.horizontalOverflow = HorizontalWrapMode.Wrap; var abilitiesElement = _cardAbilities.gameObject.AddComponent(); abilitiesElement.flexibleHeight = 1f; @@ -508,9 +512,10 @@ namespace Vanguard.UI _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 + " · ") + var otherName = Loc.OtherCardName(meta); + _cardArtName.text = Loc.CardName(meta); + _cardTitle.text = Loc.CardName(meta); + _cardSubtitle.text = (string.IsNullOrEmpty(otherName) ? string.Empty : otherName + " · ") + meta.Clan + " · G" + meta.Grade; _cardStats.text = "Тип: " + meta.Type + " · Редкость: " + meta.Rarity + "\n" + "Power " + meta.Power + " · Shield " + meta.Shield + " · Critical " + meta.Critical + "\n" @@ -519,9 +524,10 @@ namespace Vanguard.UI + " · Boost: " + (meta.HasBoost ? "да" : "нет") + " · Intercept: " + (meta.HasIntercept ? "да" : "нет") + "\n" + "Принт: " + meta.PrintingId; - _cardAbilities.text = string.IsNullOrWhiteSpace(meta.TextRu) - ? (string.IsNullOrWhiteSpace(meta.TextEn) ? "Текст способности отсутствует." : meta.TextEn) - : meta.TextRu; + var abilityText = Loc.CardText(meta); + _cardAbilities.text = string.IsNullOrWhiteSpace(abilityText) + ? Loc.Translate("Текст способности отсутствует.") + : abilityText; _cardOwned.text = "В коллекции: ×" + (_selectedItem != null ? _selectedItem.Count : 0) + " · в колоде: " + UsedByName(meta.NameId) + " · пыль: " + _dust; @@ -847,7 +853,7 @@ namespace Vanguard.UI .Where(pair => pair.Meta != null && MatchesFilter(pair.Meta)) .OrderBy(pair => pair.Meta.Clan, StringComparer.Ordinal) .ThenBy(pair => pair.Meta.Grade) - .ThenBy(pair => pair.Meta.NameEn, StringComparer.Ordinal); + .ThenBy(pair => Loc.CardName(pair.Meta), StringComparer.Ordinal); foreach (var pair in items) AddCollectionCell(content, pair.Item, pair.Meta, countTexts, showDeckCount, onAdd); @@ -886,8 +892,9 @@ namespace Vanguard.UI CardArt.ForCard(meta.NameId, meta.Clan)); var artName = UiKit.CreateText( - art, "ArtName", meta.NameEn, 12, + art, "ArtName", Loc.CardName(meta), 12, new Color(1f, 1f, 1f, 0.9f), TextAnchor.MiddleCenter); + UiKit.MakeLiteral(artName); UiKit.Stretch(artName.rectTransform); artName.rectTransform.offsetMin = new Vector2(6f, 8f); artName.rectTransform.offsetMax = new Vector2(-6f, -6f); @@ -913,7 +920,8 @@ namespace Vanguard.UI triggerRect.sizeDelta = new Vector2(34f, 20f); var name = UiKit.CreateText( - cell, "Name", meta.NameEn, 13, Color.white, TextAnchor.UpperLeft); + cell, "Name", Loc.CardName(meta), 13, Color.white, TextAnchor.UpperLeft); + UiKit.MakeLiteral(name); name.fontStyle = FontStyle.Bold; name.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(name, 32f); @@ -948,7 +956,7 @@ namespace Vanguard.UI var printingId = group.Key; var count = group.Count(); var meta = _catalog.TryGetValue(printingId, out var found) ? found : null; - var name = meta != null ? meta.NameEn : printingId; + var name = meta != null ? Loc.CardName(meta) : printingId; var row = UiKit.CreatePanel( _deckCardContent, "DeckCard_" + printingId, new Color(0.08f, 0.11f, 0.17f, 1f)); @@ -986,6 +994,7 @@ namespace Vanguard.UI var nameText = UiKit.CreateText( texts, "Name", name, 13, Color.white, TextAnchor.MiddleLeft); + UiKit.MakeLiteral(nameText); nameText.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(nameText, 30f); diff --git a/client/Assets/Vanguard/Runtime/UI/Localization.cs b/client/Assets/Vanguard/Runtime/UI/Localization.cs new file mode 100644 index 0000000..561f226 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/Localization.cs @@ -0,0 +1,505 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using UnityEngine; +using UnityEngine.UI; +using Vanguard.Api.Contracts; + +namespace Vanguard.UI +{ + public enum UiLanguage + { + Ru, + En, + } + + /// + /// Небольшой runtime-словарь интерфейса. Русские строки остаются ключами, + /// поэтому существующие экраны не нужно переписывать под отдельные id. + /// + public static class Loc + { + private const string PreferenceKey = "vanguard.ui.language"; + private static bool _initialized; + + public static UiLanguage Language { get; private set; } = UiLanguage.Ru; + public static bool IsEnglish => Language == UiLanguage.En; + public static event Action LanguageChanged; + + private static readonly Dictionary Exact = new Dictionary(StringComparer.Ordinal) + { + ["ВЫ"] = "YOU", + ["Вы"] = "You", + ["вы"] = "you", + ["да"] = "yes", + ["Бой"] = "Battle", + ["Все"] = "All", + ["нет"] = "no", + ["Вы: "] = "You: ", + ["ДРОП"] = "DROP", + ["Кадр"] = "Frame", + ["Клан"] = "Clan", + ["Пыль"] = "Dust", + ["Ранг"] = "Grade", + ["Рука"] = "Hand", + ["УРОН"] = "DAMAGE", + [" карт"] = " cards", + ["» за "] = "» for ", + ["В бой"] = "To battle", + ["ВОЙТИ"] = "LOG IN", + ["Выйти"] = "Log out", + ["Кадр "] = "Frame ", + ["Ничья"] = "Draw", + ["Пусто"] = "Empty", + ["Сброс"] = "Reset", + ["Тип: "] = "Type: ", + ["Щит: "] = "Shield: ", + ["цена "] = "cost ", + [" карт."] = " cards.", + [" пыли)"] = " dust)", + ["Атака "] = "Attack ", + ["В меню"] = "To menu", + ["Готово"] = "Done", + ["Журнал"] = "Log", + ["КОЛОДА"] = "DECK", + ["Кадр ▶"] = "Frame ▶", + ["Клан: "] = "Clan: ", + ["Колода"] = "Deck", + ["Купить"] = "Buy", + ["Отмена"] = "Cancel", + ["Пароль"] = "Password", + ["Победа"] = "Victory", + ["Ранг: "] = "Grade: ", + ["Реплей"] = "Replay", + ["СКРЫТА"] = "HIDDEN", + ["Сервер"] = "Server", + ["Токены"] = "Tokens", + ["Урон: "] = "Damage: ", + ["Фаза: "] = "Phase: ", + ["◀ Кадр"] = "◀ Frame", + [" · ход "] = " · turn ", + ["1 токен"] = "1 token", + ["\nМатч "] = "\nMatch ", + ["АТАКА "] = "ATTACK ", + ["Атака: "] = "Attack: ", + ["Всего: "] = "Total: ", + ["Готово."] = "Done.", + ["Забрать"] = "Claim", + ["Закрыть"] = "Close", + ["История"] = "History", + ["Карта: "] = "Card: ", + ["Магазин"] = "Store", + ["Победа!"] = "Victory!", + ["Принт: "] = "Printing: ", + ["С ботом"] = "Vs bot", + ["Сдаться"] = "Concede", + ["События"] = "Events", + ["Триггер"] = "Trigger", + ["Удалить"] = "Delete", + ["Фильтры"] = "Filters", + ["← Назад"] = "← Back", + [" рука "] = " hand ", + [" (всего "] = " (total ", + [" · пыль "] = " · dust ", + [" · рука "] = " · hand ", + [" · урон "] = " · damage ", + [" · шаг: "] = " · step: ", + [" карт · "] = " cards · ", + [" против "] = " vs ", + ["Действия"] = "Actions", + ["Кадров: "] = "Frames: ", + ["Колода: "] = "Deck: ", + ["Крафтим…"] = "Crafting…", + ["Купить «"] = "Buy «", + ["Матчей: "] = "Matches: ", + ["Принято."] = "Accepted.", + ["Уже есть"] = "Already owned", + [" · пыль: "] = " · dust: ", + [", эффект "] = ", effect ", + ["Все карты"] = "All cards", + ["Выбрать: "] = "Choose: ", + ["Клан: все"] = "Clan: all", + ["Коллекция"] = "Collection", + ["Нет колод"] = "No decks", + ["ПРОТИВНИК"] = "OPPONENT", + ["Поражение"] = "Defeat", + ["Противник"] = "Opponent", + ["РУКА · "] = "HAND · ", + ["Разобрать"] = "Dismantle", + ["Ранг: все"] = "Grade: all", + ["Реплей · "] = "Replay · ", + ["Скрафтить"] = "Craft", + ["Сохранить"] = "Save", + ["Триггер: "] = "Trigger: ", + ["противник"] = "opponent", + ["← В лобби"] = "← To lobby", + [" · урон "] = " · damage ", + [" · колода "] = " · deck ", + [" завершён."] = " finished.", + ["В фазу боя"] = "To battle phase", + ["Имя игрока"] = "Player name", + ["Кадров нет"] = "No frames", + ["Купить за "] = "Buy for ", + ["Маллиган: "] = "Mulligan: ", + ["Мои колоды"] = "My decks", + ["Нет реплея"] = "No replay", + ["Ожидание: "] = "Waiting: ", + ["Поражение."] = "Defeat.", + ["Проверок: "] = "Checks: ", + ["Разбираем…"] = "Dismantling…", + ["Рука пуста"] = "Hand is empty", + ["Сохраняем…"] = "Saving…", + ["Триал-деки"] = "Trial decks", + [" · решает: "] = " · deciding: ", + [" уникальных"] = " unique", + ["Активируем "] = "Activating ", + ["Бустер BT01"] = "BT01 Booster", + ["ЗОНА ЗАЩИТЫ"] = "GUARD ZONE", + ["Отклонено ("] = "Rejected (", + ["Поддержка: "] = "Boost: ", + ["Противник: "] = "Opponent: ", + ["Сохранено: "] = "Saved: ", + ["Способности"] = "Abilities", + ["без причины"] = "without reason", + ["в коллекции"] = "in collection", + ["← К истории"] = "← To history", + ["← Коллекция"] = "← Collection", + [" · Пыль "] = " · Dust ", + [" · банк "] = " · bank ", + [" · колода "] = " · deck ", + [" · в колоде "] = " · in deck ", + [" · событий: "] = " · events: ", + [" карт · "] = " cards · ", + ["3–32 символа"] = "3–32 characters", + ["Колода пуста"] = "Deck is empty", + ["Колода: 0/50"] = "Deck: 0/50", + ["Новая колода"] = "New deck", + ["Отправляем: "] = "Sending: ", + ["Разобрать (+"] = "Dismantle (+", + ["Скрафтить (−"] = "Craft (−", + ["Событий нет."] = "No events.", + ["Триггер: все"] = "Trigger: all", + [" · Редкость: "] = " · Rarity: ", + [" · в колоде: "] = " · in deck: ", + ["Атака отбита."] = "Attack was guarded.", + ["Атака прошла."] = "Attack went through.", + ["Бустер открыт"] = "Booster opened", + ["Закончить ход"] = "End turn", + ["Матч завершён"] = "Match finished", + ["Подключаемся…"] = "Connecting…", + ["Способность #"] = "Ability #", + [" · Токены "] = " · Tokens ", + [" · побед: "] = " · wins: ", + ["8–128 символов"] = "8–128 characters", + ["Бустер открыт!"] = "Booster opened!", + ["В коллекции: ×"] = "In collection: ×", + ["История матчей"] = "Match history", + ["Личный кабинет"] = "Profile", + ["Матч завершён."] = "Match finished.", + ["Отменить поиск"] = "Cancel search", + ["Создать колоду"] = "Create deck", + ["Триггер: сила "] = "Trigger: power ", + ["Ищем соперника…"] = "Searching for opponent…", + ["Колода удалена."] = "Deck deleted.", + ["Название колоды"] = "Deck name", + ["Повторить поиск"] = "Search again", + ["Пропустить Ride"] = "Skip Ride", + ["СОЗДАТЬ АККАУНТ"] = "CREATE ACCOUNT", + ["Стартовый набор"] = "Starter set", + ["Удалить колоду?"] = "Delete deck?", + ["Уже в коллекции"] = "Already in collection", + ["Бустер открыт · "] = "Booster opened · ", + ["Готовим очередь…"] = "Preparing queue…", + ["Карта разобрана."] = "Card dismantled.", + ["Карта скрафчена."] = "Card crafted.", + ["Матчей пока нет."] = "No matches yet.", + ["Нельзя разобрать"] = "Cannot dismantle", + ["Нельзя скрафтить"] = "Cannot craft", + ["Переподключиться"] = "Reconnect", + ["Поиск противника"] = "Opponent search", + ["Пропустить Guard"] = "Skip Guard", + ["Создаём аккаунт…"] = "Creating account…", + ["Выберите покупку."] = "Choose a purchase.", + ["Выбрать бесплатно"] = "Choose for free", + ["Выбрать вариант #"] = "Choose option #", + ["Выбрать триггер #"] = "Choose trigger #", + ["Выйти из аккаунта"] = "Log out", + ["Дека уже получена"] = "Deck already claimed", + ["Загружаем реплей…"] = "Loading replay…", + ["Купить за 1 токен"] = "Buy for 1 token", + ["Очередь закрыта: "] = "Queue closed: ", + ["Партия завершена."] = "Game over.", + ["Подтвердить сдачу"] = "Confirm concede", + ["Профиль обновлён."] = "Profile updated.", + ["Фильтры коллекции"] = "Collection filters", + ["Выбрать триал-деку"] = "Choose trial deck", + ["Выполняем покупку…"] = "Processing purchase…", + ["Загружаем историю…"] = "Loading history…", + ["Загружаем магазин…"] = "Loading store…", + ["Закончить маллиган"] = "Finish mulligan", + ["Карт в коллекции: "] = "Cards in collection: ", + ["Купить за 100 Gold"] = "Buy for 100 Gold", + ["Обновляем профиль…"] = "Updating profile…", + ["Повторить загрузку"] = "Retry loading", + ["Подтвердите покупку"] = "Confirm purchase", + ["Покупка не удалась."] = "Purchase failed.", + ["Пропустить анимации"] = "Skip animations", + ["Скрыть все действия"] = "Hide all actions", + [" с · окно MMR: ±"] = " with · MMR window: ±", + ["Онбординг завершён: "] = "Onboarding complete: ", + ["Повтор уже применён."] = "Replay already applied.", + ["В колоде уже 50 карт."] = "The deck already has 50 cards.", + ["Загружаем триал-деки…"] = "Loading trial decks…", + ["Подключаемся к матчу…"] = "Connecting to match…", + ["Показать все действия"] = "Show all actions", + ["Получаем новый тикет…"] = "Getting a new ticket…", + ["Применить способность"] = "Activate ability", + ["Сервер вернул ошибку "] = "Server returned error ", + ["Соединение потеряно: "] = "Connection lost: ", + [" · защита между полями"] = " · guard between fields", + [". Выберите цель атаки."] = ". Choose an attack target.", + ["Выберите цель на поле."] = "Choose a target on the field.", + ["Колод пока нет. Пыль: "] = "No decks yet. Dust: ", + ["Не удалось связаться: "] = "Could not connect: ", + ["Пропустить способность"] = "Skip ability", + ["Сервер не выдал тикет."] = "Server did not issue a ticket.", + ["Триал-деки недоступны."] = "Trial decks are unavailable.", + ["Ничья или матч прерван."] = "Draw or match interrupted.", + ["Открыть бустер BT01 за "] = "Open BT01 booster for ", + ["» добавлена в коллекцию."] = "» added to collection.", + ["Не удалось создать матч."] = "Could not create a match.", + ["Сейчас решает противник…"] = "Opponent is deciding…", + ["Не удалось подключиться: "] = "Could not connect: ", + ["Ошибка отправки интента: "] = "Failed to send intent: ", + ["Сначала сохраните колоду."] = "Save the deck first.", + ["RE:MASTER · СЕТЕВОЙ КЛИЕНТ"] = "RE:MASTER · NETWORK CLIENT", + ["Матч найден! Подключаемся…"] = "Match found! Connecting…", + ["Не удалось удалить колоду."] = "Could not delete the deck.", + ["Готовим тренировочный матч…"] = "Preparing practice match…", + ["Не удалось разобрать карту."] = "Could not dismantle the card.", + ["Не удалось скрафтить карту."] = "Could not craft the card.", + ["Переподключение не удалось."] = "Reconnect failed.", + ["Состояние кадра недоступно."] = "Frame state unavailable.", + ["Classic Ranked · скрытый MMR"] = "Classic Ranked · hidden MMR", + ["Введите имя игрока и пароль."] = "Enter player name and password.", + ["Не удалось загрузить реплей."] = "Could not load replay.", + ["Не удалось сохранить колоду."] = "Could not save the deck.", + ["Триггер: выберите цель силы."] = "Trigger: choose a power target.", + [". ПКМ по карте — подробности."] = ". Right-click a card for details.", + ["Загружаем коллекцию и колоды…"] = "Loading collection and decks…", + ["Не удалось загрузить историю."] = "Could not load history.", + ["Не удалось загрузить магазин."] = "Could not load store.", + ["Состав появится после выбора."] = "The lineup appears after selection.", + [" · ПКМ по карте — подробности."] = " · Right-click a card for details.", + ["Не больше 4 копий одной карты."] = "No more than 4 copies of a card.", + ["Не удалось обновить состояние."] = "Could not refresh state.", + ["Текст способности отсутствует."] = "Ability text is missing.", + ["Не удалось завершить онбординг."] = "Could not complete onboarding.", + ["Не удалось загрузить декбилдер."] = "Could not load deck builder.", + ["Не удалось начать матч с ботом."] = "Could not start a match against the bot.", + ["Триггер: выберите цель эффекта."] = "Trigger: choose an effect target.", + [".\nВыберите цель атаки или «Отмена»."] = ".\nChoose an attack target or “Cancel”.", + ["Нажмите «Подтвердить сдачу» ещё раз."] = "Press “Confirm concede” again.", + ["Очередь недоступна. Проверьте сервер."] = "Queue unavailable. Check the server.", + ["Сервер не вернул ни одной триал-деки."] = "Server returned no trial decks.", + ["Соединение открыто. Ожидаем состояние…"] = "Connection open. Waiting for state…", + ["В коллекции больше нет свободных копий."] = "No free copies left in the collection.", + ["Выберите деку — выбор делается один раз."] = "Choose a deck — this can be done only once.", + ["\nКолода будет удалена без восстановления."] = "\nThe deck will be deleted permanently.", + ["Нажмите на колоду,\nчтобы открыть редактор."] = "Click a deck\nto open the editor.", + ["ЛКМ — убрать копию,\nПКМ — посмотреть карту."] = "LMB — remove a copy,\nRMB — view the card.", + ["Для этой карты сейчас нет доступного действия."] = "No action is available for this card right now.", + ["Только ваши матчи · реплей доступен участникам"] = "Only your matches · replay available to participants", + ["Не удалось обновить профиль. Сервер недоступен?"] = "Could not update profile. Is the server down?", + ["Не удалось загрузить триал-деки. Сервер недоступен?"] = "Could not load trial decks. Is the server down?", + ["Онбординг не завершён: выберите стартовую триал-деку."] = "Onboarding incomplete: choose a starter trial deck.", + [" · стенд 192.168.31.158:18080 · localhost:5080"] = " · stand 192.168.31.158:18080 · localhost:5080", + ["Нет связи с сервером. Проверьте адрес и запущен ли сервер."] = "No connection to the server. Check the address and that the server is running.", + ["Выберите цель на поле.\nНажмите «Отмена», чтобы снять выбор."] = "Choose a target on the field.\nPress “Cancel” to clear the selection.", + ["5 карт: 4 C + 1 R+ · duplicate protection · 100 Gold или 1 токен."] = "5 cards: 4 C + 1 R+ · duplicate protection · 100 Gold or 1 token.", + ["Нажмите на карту в руке или на поле.\nДопустимые цели подсветятся."] = "Click a card in hand or on the field.\nValid targets will be highlighted.", + ["Выберите одну триал-деку. 50 карт попадут в коллекцию, плюс 5 токенов на бустеры."] = "Choose one trial deck. 50 cards will be added to your collection, plus 5 tokens for boosters.", + ["Unity блокирует HTTP. Player Settings → Allow downloads over HTTP = Always, затем перезапусти Unity."] = "Unity blocks HTTP. Player Settings → Allow downloads over HTTP = Always, then restart Unity.", + ["VanguardApp не создан. Запусти игру из Boot или через Play в любой сцене клиента."] = "VanguardApp was not created. Start the game from Boot or via Play in any client scene.", + }; + + private static readonly KeyValuePair[] Phrases = Exact + .Where(pair => IsPhraseKey(pair.Key)) + .OrderByDescending(pair => pair.Key.Length) + .ToArray(); + + private static readonly Regex YesWord = new Regex( + @"\bда\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex NoWord = new Regex( + @"\bнет\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + public static void Initialize() + { + if (_initialized) + return; + + _initialized = true; + Language = PlayerPrefs.GetString(PreferenceKey, "ru") == "en" + ? UiLanguage.En + : UiLanguage.Ru; + } + + public static void SetLanguage(UiLanguage language) + { + Initialize(); + if (Language == language) + return; + + Language = language; + PlayerPrefs.SetString(PreferenceKey, language == UiLanguage.En ? "en" : "ru"); + PlayerPrefs.Save(); + LanguageChanged?.Invoke(); + } + + public static void Toggle() => + SetLanguage(IsEnglish ? UiLanguage.Ru : UiLanguage.En); + + public static string Translate(string source) + { + if (string.IsNullOrEmpty(source) || !IsEnglish) + return source; + + if (Exact.TryGetValue(source, out var exact)) + return exact; + + var result = source; + foreach (var pair in Phrases) + { + if (result.Contains(pair.Key, StringComparison.Ordinal)) + result = result.Replace(pair.Key, pair.Value, StringComparison.Ordinal); + } + + result = YesWord.Replace(result, "yes"); + result = NoWord.Replace(result, "no"); + return result; + } + + public static string CardName(CardCatalogItemDto card) + { + if (card == null) + return string.Empty; + + var localized = IsEnglish ? card.NameEn : card.NameRu; + return string.IsNullOrWhiteSpace(localized) ? card.NameEn : localized; + } + + public static string OtherCardName(CardCatalogItemDto card) + { + if (card == null) + return string.Empty; + + var other = IsEnglish ? card.NameRu : card.NameEn; + return string.IsNullOrWhiteSpace(other) ? string.Empty : other; + } + + public static string CardText(CardCatalogItemDto card) + { + if (card == null) + return string.Empty; + + var localized = IsEnglish ? card.TextEn : card.TextRu; + if (!string.IsNullOrWhiteSpace(localized)) + return localized; + + return card.TextEn ?? string.Empty; + } + + private static bool IsPhraseKey(string key) + { + if (key.Length >= 6) + return true; + + return key.Any(character => + character == ' ' || character == ':' || character == '.' || + character == ',' || character == '!' || character == '?' || + character == '«' || character == '»' || character == '·' || + character == '—' || character == '–' || character == '\n' || + character == '(' || character == ')' || character == '±' || + character == '←' || character == '→'); + } + } + + /// + /// Переводит uGUI-лейбл при смене языка и подхватывает текст, выставленный кодом. + /// + [DisallowMultipleComponent] + public sealed class LocalizedText : MonoBehaviour + { + private Text _text; + private string _source = string.Empty; + private string _applied = string.Empty; + private bool _literal; + private bool _subscribed; + + private void Awake() => Subscribe(); + + private void OnDestroy() => Unsubscribe(); + + public void Initialize(string source) + { + _text = GetComponent(); + _source = source ?? string.Empty; + _literal = false; + Subscribe(); + ApplyCurrent(); + } + + private void Subscribe() + { + if (_subscribed) + return; + + Loc.LanguageChanged += ApplyCurrent; + _subscribed = true; + } + + private void Unsubscribe() + { + if (!_subscribed) + return; + + Loc.LanguageChanged -= ApplyCurrent; + _subscribed = false; + } + + public void SetLiteral(string value) + { + _literal = true; + _source = value ?? string.Empty; + _applied = _source; + if (_text != null) + _text.text = _source; + } + + private void LateUpdate() => ApplyNow(); + + public void ApplyNow() + { + if (_literal || _text == null) + return; + + if (!string.Equals(_text.text, _applied, StringComparison.Ordinal)) + _source = _text.text; + + ApplyCurrent(); + } + + private void ApplyCurrent() + { + if (_literal || _text == null) + return; + + _applied = Loc.Translate(_source); + if (!string.Equals(_text.text, _applied, StringComparison.Ordinal)) + _text.text = _applied; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/UI/Localization.cs.meta b/client/Assets/Vanguard/Runtime/UI/Localization.cs.meta new file mode 100644 index 0000000..f508e1a --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/Localization.cs.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 9dc96684cbf149d49fc1b94b0800041a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + diff --git a/client/Assets/Vanguard/Runtime/UI/LoginView.cs b/client/Assets/Vanguard/Runtime/UI/LoginView.cs index 5c2b620..3e0c36d 100644 --- a/client/Assets/Vanguard/Runtime/UI/LoginView.cs +++ b/client/Assets/Vanguard/Runtime/UI/LoginView.cs @@ -110,6 +110,14 @@ namespace Vanguard.UI 14, new Color(0.38f, 0.46f, 0.62f)); UiKit.SetPreferredHeight(footer, 24f); + + var language = UiKit.CreateLanguageButton(_root, "Language"); + var languageRect = language.GetComponent(); + languageRect.anchorMin = new Vector2(1f, 1f); + languageRect.anchorMax = new Vector2(1f, 1f); + languageRect.pivot = new Vector2(1f, 1f); + languageRect.anchoredPosition = new Vector2(-24f, -24f); + languageRect.sizeDelta = new Vector2(72f, 40f); } public void Show(string status = null) diff --git a/client/Assets/Vanguard/Runtime/UI/MetaView.cs b/client/Assets/Vanguard/Runtime/UI/MetaView.cs index 08d3a74..95ff7cc 100644 --- a/client/Assets/Vanguard/Runtime/UI/MetaView.cs +++ b/client/Assets/Vanguard/Runtime/UI/MetaView.cs @@ -73,8 +73,12 @@ namespace Vanguard.UI _playerName = UiKit.CreateText( top, "PlayerName", "…", 20, new Color(0.70f, 0.76f, 0.90f), TextAnchor.MiddleLeft); + UiKit.MakeLiteral(_playerName); UiKit.SetPreferredHeight(_playerName, 40f); + var language = UiKit.CreateLanguageButton(top, "Language"); + UiKit.SetPreferredHeight(language, 42f, 0f); + _logout = UiKit.CreateButton(top, "Logout", "Выйти", OnLogoutClicked, 18); UiKit.SetPreferredHeight(_logout, 42f, 0f); diff --git a/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs b/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs index 15667d7..7dde75b 100644 --- a/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs +++ b/client/Assets/Vanguard/Runtime/UI/OnboardingView.cs @@ -148,6 +148,7 @@ namespace Vanguard.UI UiKit.AddVerticalLayout(card, 24, 10); var name = UiKit.CreateText(card, "Name", deck.Name, 30, Color.white, TextAnchor.MiddleLeft); + UiKit.MakeLiteral(name); name.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(name, 42f); diff --git a/client/Assets/Vanguard/Runtime/UI/UiKit.cs b/client/Assets/Vanguard/Runtime/UI/UiKit.cs index 7989e81..2db7118 100644 --- a/client/Assets/Vanguard/Runtime/UI/UiKit.cs +++ b/client/Assets/Vanguard/Runtime/UI/UiKit.cs @@ -144,10 +144,21 @@ namespace Vanguard.UI text.verticalOverflow = VerticalWrapMode.Overflow; text.raycastTarget = false; text.supportRichText = true; + text.gameObject.AddComponent().Initialize(content); return text; } + public static void MakeLiteral(Text text) + { + if (text == null) + return; + + var localized = text.GetComponent(); + if (localized != null) + localized.SetLiteral(text.text); + } + public static Button CreateButton( Transform parent, string name, @@ -173,6 +184,29 @@ namespace Vanguard.UI return button; } + public static Button CreateLanguageButton(Transform parent, string name = "Language") + { + Button button = null; + button = CreateButton( + parent, + name, + Loc.IsEnglish ? "RU" : "EN", + () => + { + Loc.Toggle(); + var label = button != null ? button.GetComponentInChildren() : null; + if (label != null) + label.text = Loc.IsEnglish ? "RU" : "EN"; + }, + 17); + + var text = button.GetComponentInChildren(); + if (text != null) + MakeLiteral(text); + + return button; + } + public static InputField CreateInput( Transform parent, string name, @@ -203,6 +237,7 @@ namespace Vanguard.UI input.selectionColor = new Color(0.20f, 0.42f, 0.78f, 0.6f); var text = CreateText(rect, "Text", string.Empty, 22, Color.white, TextAnchor.MiddleLeft); + MakeLiteral(text); Stretch(text.rectTransform); var textRect = text.rectTransform; textRect.offsetMin = new Vector2(16f, 0f); diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 18df003..6021613 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -2,6 +2,7 @@ using System.Linq; using NUnit.Framework; using UnityEngine; using UnityEngine.EventSystems; +using UnityEngine.UI; using Vanguard.Api; using Vanguard.Api.Contracts; using Vanguard.App; @@ -75,6 +76,39 @@ namespace Vanguard.Tests Cleanup(); } + [Test] + public void Localization_Translates_Static_And_Dynamic_Strings() + { + Loc.SetLanguage(UiLanguage.Ru); + Assert.AreEqual("Коллекция", Loc.Translate("Коллекция")); + + Loc.SetLanguage(UiLanguage.En); + Assert.AreEqual("Collection", Loc.Translate("Коллекция")); + Assert.AreEqual("Grade: all", Loc.Translate("Ранг: все")); + Assert.AreEqual("Cards in collection: 12", Loc.Translate("Карт в коллекции: 12")); + Assert.AreEqual("Victory!", Loc.Translate("Победа!")); + Assert.AreEqual("Sentinel: yes · Boost: no", Loc.Translate("Sentinel: да · Boost: нет")); + + Loc.SetLanguage(UiLanguage.Ru); + + var go = new GameObject("LocalizedLabel", typeof(RectTransform), typeof(Text)); + var label = go.GetComponent(); + var localized = go.AddComponent(); + localized.Initialize("Коллекция"); + Assert.AreEqual("Коллекция", label.text); + + Loc.SetLanguage(UiLanguage.En); + Assert.AreEqual("Collection", label.text); + + label.text = "Магазин"; + localized.ApplyNow(); + Assert.AreEqual("Store", label.text); + + Loc.SetLanguage(UiLanguage.Ru); + Assert.AreEqual("Магазин", label.text); + Object.DestroyImmediate(go); + } + private static bool Exists(string name) => Resources.FindObjectsOfTypeAll().Any(go => go.name == name); diff --git a/client/README.md b/client/README.md index ecad6b6..f2c4a71 100644 --- a/client/README.md +++ b/client/README.md @@ -62,24 +62,28 @@ http://localhost:5080 без истории/MMR/наград. - `GameConnection`: WebSocket + MessagePack, handshake, snapshot/events, интенты, ответы на ping, закрытие и переподключение. -- Боевой gray-box: доска, рука, часы, кнопки всех легальных интентов, +- Боевой экран: доска, рука, часы, кнопки всех легальных интентов, сдача с подтверждением и экран результата. - Боевой UX: клик по картам/зонам, подсветка целей, сгруппированные действия, выбор силы/эффекта триггера, журнал событий и подсказки; event-анимации с очередью и скипом. -- Карты в бою и коллекции: процедурные арты по клану, рамка, имя, ранг, - триггер, сила/крит, REST и рубашка для скрытых карт. +- Карты в бою и коллекции: реальные арты, рамка по клану, имя, ранг, + триггер, сила/крит, REST и рубашка для скрытых карт; call/ride-въезд, + пульс атаки и призрак ухода в дроп. - Реальные арты: `CardImageService` ищет `Resources/CardArt/{imageKey}`; `imageKey` приходит из `/api/cards`. Скачаны все **124** арта; импорт — `tools/import_card_art.py`. +- Локализация RU/EN: `Localization.cs` (`Loc` + `LocalizedText`), + переключатель в Login/Lobby, сохранение в `PlayerPrefs`; имена и тексты + карт берутся из `nameRu/nameEn` и `textRu/textEn`. - Экран входа и главный экран с балансами, MMR и переходами в разделы. ## Что дальше -1. Довести боевой UI: арты карт, зоны, анимации, скип/ускорение. +1. Drive/damage-анимации карт и стола, звук, ускорение/скип. 2. Реконнект на всех шагах и аккуратная обработка таймаутов. 3. Декбилдер: drag&drop, live-валидация, редкость/тип/арт, пресеты. -4. Локализация RU/EN, настройки, DOTween-анимации. +4. Настройки (язык уже есть), DOTween-анимации. ## Плагины @@ -102,10 +106,9 @@ MessagePack-сериализатор и юнион-кадры. защищённое хранилище. - Текущий UI собран кодом на uGUI; каждая сцена имеет свой Canvas. Префабы/UI Toolkit подключим, когда появятся макеты. -- DOTween пока не добавлен; анимации UI — после перехода на сцены. +- DOTween пока не добавлен; текущие анимации карт — собственные корутины/тики. - Мета-экраны — заглушки: сеть и авторизация настоящие, разделы следующие. -- Бой — gray-box: карты показываются по `printingId`, без артов и анимаций; - действия — кнопки всех `MatchView.LegalIntents`. +- В бою нет звука и отдельных drive/damage-анимаций; остальное работает. - `GameConnection` использует `System.Net.WebSockets.ClientWebSocket` (Windows/десктоп first). Для WebGL понадобится другой транспорт или нативный WebSocket-плагин. @@ -131,4 +134,5 @@ Smoke сцен (входит в Play Mode, открывает Boot → Login, п ``` Тесты не ходят в сеть: проверяются composition root, создание Canvas, -экранов и 3D-окружения. +экранов, импорт арта и локализация (статические строки, динамика, +live-переключение). Сейчас **3/3**. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 0260ef6..6079f9b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # Передача сессии -Состояние проекта на **21 октября 2026**. Документ нужен, чтобы новая сессия +Состояние проекта на **22 октября 2026**. Документ нужен, чтобы новая сессия продолжила работу без повторного разбора истории. Краткая оперативная сводка — `docs/SESSION-STATE.md`; этот файл хранит подробную историю. @@ -23,7 +23,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты - ветка `master`; - remote `ssh://git@192.168.31.158:2222/mpakobecex/VanguardReMaster.git`; -- актуальный HEAD смотреть через `git log -1` (на момент записи — `6ed0998`); +- актуальный HEAD смотреть через `git log -1`; - рабочее дерево чистое. Главные принципы: @@ -47,7 +47,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Протокол и контракты | `docs/protocol.md`, `Vanguard.Shared` готовы | | Сервер | сеть, матчмейкинг, PostgreSQL + Redis, Orleans, Docker | | Мета-API | JWT, коллекция, колоды, магазин, экономика, история, реплеи | -| Клиент | Unity 6.6, 3D-сцены Login/Lobby/DeckBuilder/Battle, логин/коллекция/колоды/очередь/gray-box бой | +| Клиент | Unity 6.6, 3D-сцены Login/Lobby/DeckBuilder/Battle, логин/коллекция/колоды/очередь/бой, 124 арта, RU/EN, анимации карт | | Лаунчер | не начат | Блок «Игровое ядро» из `IMPLEMENTATION_CHECKLIST.md` закрыт по его критериям: @@ -762,3 +762,31 @@ Battle; Boot и общие сервисы остаются персистент дроп справа, зеркально у противника, зона защиты между полями. - Проверки визуального блока: Unity EditMode 2/2, `SCENE_SMOKE success=True`, live-практика с ботом на стенде. + +## 2026-10-22 — перемещения карт и локализация RU/EN + +- Перемещения карт в бою: + - при call/ride карта въезжает из руки (свой) или сверху (противник), + с scale-in; + - атакующий и цель пульсируют; + - карта, ушедшая из слота в дроп, оставляет призрак: летит вниз/вверх, + уменьшается и растворяется; для закрытой карты противника используется + рубашка, лицо не раскрывается; + - позиции карт снимаются после layout, поэтому анимации не зависят от + внешних твинов; при новом snapshot анимации пересобираются. +- Локализация: + - `Localization.cs`: `Loc` + `LocalizedText`; русская строка — ключ, + английский перевод — словарь + фразовые подстановки для динамических + строк; `да/нет` переводятся по границам слов; + - `UiKit.CreateText` автоматически вешает `LocalizedText`, поэтому + существующие экраны переведены без переписывания; + - переключатель `EN`/`RU` в Login и Lobby; выбор в `PlayerPrefs` + (`vanguard.ui.language`), применяется на лету; + - имена/тексты карт: `Loc.CardName` / `Loc.CardText` выбирают + `nameRu/nameEn` и `textRu/textEn`; карточные поля помечены literal, + чтобы словарь не портил переводы карт. +- Проверки: Unity EditMode **3/3** (экраны, импорт арта, перевод статики и + динамики, live-смена языка), `SCENE_SMOKE success=True active=Login`. +- Ограничения: звука пока нет; полноценные drive/damage-анимации карт и + стола — следующий блок; локализация сделана runtime-словарём, без + Unity Localization-таблиц (пакет остаётся подключён). diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 515e933..cecf2d6 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,7 +1,8 @@ # Vanguard ReMaster — состояние сессии -**Дата:** 2026-10-21 -**HEAD:** см. `git log -1` +**Дата:** 2026-10-22 + +**HEAD:** см. `git log -1` **Коротко:** сервер готов, клиент — рабочий каркас с 3D-сценами. ## Правила работы @@ -49,19 +50,26 @@ - Декбилдер: `DeckBuilderView`; каталог метаданных — `GET /api/cards` (grade, trigger, clan, rarity, printing, power/shield/type, текст способности), клиент объединяет с коллекцией. - Изображения карт пока заглушки-плашки по цвету клана. + Изображения карт — реальные арты из `Resources/CardArt` (124/124), с + процедурным fallback по цвету клана. - ПКМ по карте — оверлей с картой, способностями, разбором в пыль и крафтом; SP/TD не разбираются и не крафтятся. +- Локализация RU/EN: `Loc` + `LocalizedText`; переключатель в Login и + Lobby (кнопка `EN`/`RU`), выбор сохраняется в `PlayerPrefs`. Русские + строки остаются ключами, переводятся и статические, и динамические + подписи; имена/тексты карт берутся из `nameRu/nameEn` и `textRu/textEn`. - Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер создаёт casual-матч против случайного бота (`POST /api/game/practice`), без истории, MMR и наград. -- Боевой экран: процедурные карты с арт-градиентом по клану, рамкой, - именем, рангом, триггером, силой/критом и рубашкой. +- Боевой экран: реальные арты карт, рамка по клану, имя, ранг, триггер, + сила/крит, REST и рубашка; процедурные плашки остаются fallback. - Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона (+soul), справа колода и ниже дроп; у противника зеркально; между полями — зона защиты со щитом и проверками. - Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации». + Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют, + ушедшие в дроп карты летят призраком и растворяются. - Реальные арты: `imageKey` из `/api/cards` + `CardImageService` ищет `Resources/CardArt/{imageKey}`. Скачаны все 124 арта. Импорт: `tools/import_card_art.py`. @@ -70,8 +78,8 @@ выбор силы/эффекта триггера, журнал последних событий. - Отдельная сцена `Collection` и `CollectionView` удалены; коллекция открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). -- Не готово: файлы реальных артов (пайплайн готов), 3D-модели, - полноценная очередь анимаций, локализация RU/EN, настройки, DOTween. +- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween, + drag&drop и live-валидация декбилдера. ## Запуск @@ -103,6 +111,10 @@ curl http://127.0.0.1:18080/health -executeMethod Vanguard.Editor.SceneSmoke.Run ``` +Клиентские проверки: **3/3** EditMode (сборка всех экранов, импорт арта, +перевод статических/динамических строк и live-переключение языка), +`SCENE_SMOKE success=True`. + `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены: @@ -126,7 +138,7 @@ client/Assets/Vanguard/Runtime/App/ client/Assets/Vanguard/Runtime/UI/ LoginView.cs MetaView.cs OnboardingView.cs DeckBuilderView.cs QueueView.cs BattleView.cs - UiKit.cs + UiKit.cs Localization.cs client/Assets/Vanguard/Runtime/Battle/GameConnection.cs client/Assets/Vanguard/Runtime/Api/ApiClient.cs + ApiContracts.cs client/Assets/Vanguard/Editor/SceneBuilder.cs + SceneSmoke.cs @@ -149,13 +161,12 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack ## Следующие шаги -1. Боевой UI: арты, зоны, анимации, скип; реконнект и таймауты. -2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты. -3. 3D-наполнение: модели/арт, prefab-экраны, камера. -4. Локализация RU/EN, настройки, DOTween. -5. Лаунчер WPF. +1. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты. +2. 3D-наполнение: модели/арт, prefab-экраны, камера. +3. Звук и настройки, DOTween. +4. Лаунчер WPF. -Тренировка с ботом уже есть — следующий блок можно начинать с боевого UI. +Тренировка с ботом и боевой UI с артами/анимациями/локализацией уже есть. ## Подводные камни