Animate card movement and add RU/EN localization
CI / test (push) Successful in 2m6s Details

This commit is contained in:
MpakobecEx 2026-10-22 01:26:14 +05:00
parent b81f5e3dd0
commit 0e2907803a
15 changed files with 1013 additions and 61 deletions

View File

@ -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 после реконнекта (без переигрывания анимаций).

View File

@ -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` — исходный дизайн-документ.

View File

@ -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();

View File

@ -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<string, RectTransform> _cardPanels =
new Dictionary<string, RectTransform>(StringComparer.Ordinal);
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
private readonly List<CardEntry> _pendingEntries = new List<CardEntry>();
private readonly List<GhostAnimation> _ghostAnimations = new List<GhostAnimation>();
private readonly Dictionary<string, string> _cardPrintings =
new Dictionary<string, string>(StringComparer.Ordinal);
private readonly Dictionary<string, bool> _cardFaceUp =
new Dictionary<string, bool>(StringComparer.Ordinal);
private readonly Dictionary<string, CardAnchor> _cardAnchors =
new Dictionary<string, CardAnchor>(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<Image>().raycastTarget = false;
_flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent<Image>();
_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<string, CardAnchor> 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>();
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>();
outline.effectColor = new Color(1f, 0.35f, 0.28f, 0.9f);
outline.effectDistance = new Vector2(2f, -2f);
var group = panel.gameObject.AddComponent<CanvasGroup>();
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<string, CardAnchor>(_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)
{

View File

@ -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<LayoutElement>();
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);

View File

@ -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,
}
/// <summary>
/// Небольшой runtime-словарь интерфейса. Русские строки остаются ключами,
/// поэтому существующие экраны не нужно переписывать под отдельные id.
/// </summary>
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<string, string> Exact = new Dictionary<string, string>(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<string, string>[] 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 == '→');
}
}
/// <summary>
/// Переводит uGUI-лейбл при смене языка и подхватывает текст, выставленный кодом.
/// </summary>
[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<Text>();
_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;
}
}
}

View File

@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: 9dc96684cbf149d49fc1b94b0800041a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<RectTransform>();
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)

View File

@ -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);

View File

@ -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);

View File

@ -144,10 +144,21 @@ namespace Vanguard.UI
text.verticalOverflow = VerticalWrapMode.Overflow;
text.raycastTarget = false;
text.supportRichText = true;
text.gameObject.AddComponent<LocalizedText>().Initialize(content);
return text;
}
public static void MakeLiteral(Text text)
{
if (text == null)
return;
var localized = text.GetComponent<LocalizedText>();
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<Text>() : null;
if (label != null)
label.text = Loc.IsEnglish ? "RU" : "EN";
},
17);
var text = button.GetComponentInChildren<Text>();
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);

View File

@ -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<Text>();
var localized = go.AddComponent<LocalizedText>();
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<GameObject>().Any(go => go.name == name);

View File

@ -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**.

View File

@ -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-таблиц (пакет остаётся подключён).

View File

@ -1,6 +1,7 @@
# Vanguard ReMaster — состояние сессии
**Дата:** 2026-10-21
**Дата:** 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 с артами/анимациями/локализацией уже есть.
## Подводные камни