using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.UI; using Vanguard.Api; using Vanguard.Api.Contracts; using Vanguard.Battle; using Vanguard.Meta; using Vanguard.Shared.Protocol; namespace Vanguard.UI { /// /// Серый боевой экран: доска, рука, часы и кнопки всех легальных интентов. /// Красоту и анимации добавим поверх этой рабочей логики. /// public sealed class BattleView { private static readonly Color Background = new Color(0.035f, 0.045f, 0.065f, 1f); private static readonly Color Panel = new Color(0.07f, 0.085f, 0.115f, 1f); private static readonly Color Card = new Color(0.10f, 0.13f, 0.18f, 1f); private static readonly Color Muted = new Color(0.62f, 0.68f, 0.82f); private static readonly Color Accent = new Color(0.20f, 0.42f, 0.78f, 1f); private readonly MetaService _meta; private readonly Action _onExit; private readonly Dictionary _catalog = new Dictionary(StringComparer.Ordinal); private bool _catalogLoading; private readonly RectTransform _root; private readonly RectTransform _board; private RectTransform _intents; private readonly Text _topInfo; private readonly Text _clock; private readonly Text _status; private readonly Button _concede; private readonly Text _concedeLabel; private readonly Button _reconnect; private readonly RectTransform _result; private RectTransform _logContent; private Text _resultText; private Text _feedback; private Image _flash; private float _feedbackTime; private float _feedbackDuration; private Color _feedbackColor; private readonly Queue _feedbackQueue = new Queue(); private Button _skipFeedback; private bool _feedbackPlaying; private readonly List _logLines = new List(); private GameConnection _connection; private CancellationTokenSource _cts; private MatchConnectionInfo _info; private MatchView _view; private MatchView _previousView; private bool _connecting; private bool _sending; private bool _snapshotPending; private bool _finished; private bool _confirmConcede; private bool _showAllIntents; private float _confirmDeadline; private long _clockServerMs; private long _clockReceivedMs; private readonly List _pendingIntents = new List(); private readonly HashSet _highlightKeys = new HashSet(StringComparer.Ordinal); private int _pendingBoosterSlot = -1; private int? _triggerPowerSlot; 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) { _meta = meta; _onExit = onExit; _root = UiKit.CreatePanel(parent, "BattleScreen", Background); var top = UiKit.CreatePanel(_root, "TopBar", Panel); top.anchorMin = new Vector2(0f, 1f); top.anchorMax = new Vector2(1f, 1f); top.pivot = new Vector2(0.5f, 1f); top.anchoredPosition = Vector2.zero; top.sizeDelta = new Vector2(0f, 88f); UiKit.AddHorizontalLayout(top, 20, 14); var title = UiKit.CreateText(top, "Title", "Бой", 32, Color.white, TextAnchor.MiddleLeft); title.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(title, 46f, 0f); _topInfo = UiKit.CreateText( top, "Match", string.Empty, 17, Muted, TextAnchor.MiddleLeft); UiKit.MakeLiteral(_topInfo); UiKit.SetPreferredHeight(_topInfo, 38f); _clock = UiKit.CreateText( top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight); UiKit.SetPreferredHeight(_clock, 44f, 0f); _concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18); _concedeLabel = _concede.GetComponentInChildren(); UiKit.SetPreferredHeight(_concede, 44f, 0f); var left = UiKit.CreatePanel(_root, "BoardPanel", Background); left.anchorMin = Vector2.zero; left.anchorMax = Vector2.one; left.offsetMin = new Vector2(36f, 28f); left.offsetMax = new Vector2(-564f, -108f); UiKit.AddVerticalLayout(left, 8, 8); _board = left; var right = UiKit.CreatePanel(_root, "IntentPanel", Panel); right.anchorMin = new Vector2(1f, 0f); right.anchorMax = new Vector2(1f, 1f); right.pivot = new Vector2(1f, 0.5f); right.offsetMin = new Vector2(-528f, 28f); right.offsetMax = new Vector2(-36f, -108f); UiKit.AddVerticalLayout(right, 14, 10); var actionsTitle = UiKit.CreateText( right, "ActionsTitle", "Действия", 24, Color.white, TextAnchor.MiddleLeft); actionsTitle.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(actionsTitle, 32f); var scroll = UiKit.CreateScrollView(right, "IntentScroll", out _intents, 6f); var scrollElement = scroll.gameObject.AddComponent(); scrollElement.flexibleHeight = 1f; scrollElement.minHeight = 200f; _status = UiKit.CreateText( right, "Status", string.Empty, 16, Muted, TextAnchor.UpperLeft); _status.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(_status, 74f); _skipFeedback = UiKit.CreateButton( right, "SkipFeedback", "Пропустить анимации", SkipFeedback, 15); UiKit.SetPreferredHeight(_skipFeedback, 38f); _skipFeedback.gameObject.SetActive(false); var logTitle = UiKit.CreateText( right, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft); logTitle.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(logTitle, 22f); var logScroll = UiKit.CreateScrollView(right, "LogScroll", out _logContent, 3f); var logElement = logScroll.gameObject.AddComponent(); logElement.preferredHeight = 142f; logElement.minHeight = 80f; _reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18); UiKit.SetPreferredHeight(_reconnect, 46f); _reconnect.gameObject.SetActive(false); _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); _feedback = UiKit.CreateText( _root, "BattleFeedback", string.Empty, 44, Color.white, TextAnchor.MiddleCenter); _feedback.fontStyle = FontStyle.Bold; _feedback.raycastTarget = false; var feedbackRect = _feedback.rectTransform; feedbackRect.anchorMin = new Vector2(0.5f, 0.5f); feedbackRect.anchorMax = new Vector2(0.5f, 0.5f); feedbackRect.pivot = new Vector2(0.5f, 0.5f); feedbackRect.sizeDelta = new Vector2(900f, 120f); feedbackRect.anchoredPosition = new Vector2(0f, 130f); _feedback.gameObject.SetActive(false); _root.gameObject.SetActive(false); } public void Show(MatchConnectionInfo info) { _info = info; _view = null; _previousView = null; _finished = false; _confirmConcede = false; _concedeLabel.text = "Сдаться"; _concede.interactable = true; _reconnect.gameObject.SetActive(false); _result.gameObject.SetActive(false); _pendingIntents.Clear(); _highlightKeys.Clear(); _pendingBoosterSlot = -1; _triggerPowerSlot = null; _showAllIntents = false; _logLines.Clear(); RebuildLog(); _feedbackQueue.Clear(); _feedbackPlaying = false; _feedbackTime = 0f; _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); _ = LoadCatalogAsync(); _ = ConnectAsync(); } public void Hide() { _cts?.Cancel(); _cts?.Dispose(); _cts = null; if (_connection != null) { var connection = _connection; _connection = null; _ = connection.DisposeAsync().AsTask(); } _root.gameObject.SetActive(false); _result.gameObject.SetActive(false); _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); _view = null; } public void Tick() { if (_confirmConcede && Time.unscaledTime > _confirmDeadline) { _confirmConcede = false; _concedeLabel.text = "Сдаться"; } UpdateClock(); UpdateFeedback(); UpdateCardAnimations(); } private void ShowFeedback(string text, Color color, float duration = 1.25f, bool flash = false) { _feedbackQueue.Enqueue(new FeedbackStep(text, color, duration, flash)); if (!_feedbackPlaying) StartNextFeedback(); } private void StartNextFeedback() { if (_feedbackQueue.Count == 0) { _feedbackPlaying = false; _skipFeedback.gameObject.SetActive(false); return; } var step = _feedbackQueue.Dequeue(); _feedbackPlaying = true; _feedback.text = step.Text; _feedbackColor = step.Color; _feedbackDuration = step.Duration; _feedbackTime = step.Duration; _feedback.gameObject.SetActive(true); _feedback.rectTransform.localScale = Vector3.one * 1.12f; _skipFeedback.gameObject.SetActive(true); if (step.Flash) { _flash.gameObject.SetActive(true); _flash.color = new Color(step.Color.r, step.Color.g, step.Color.b, 0.22f); } } private void UpdateFeedback() { if (!_feedbackPlaying) return; _feedbackTime -= Time.unscaledDeltaTime; var t = Mathf.Clamp01(_feedbackTime / Mathf.Max(0.01f, _feedbackDuration)); var alpha = Mathf.Clamp01(t * 1.8f); _feedback.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha); _feedback.rectTransform.localScale = Vector3.one * Mathf.Lerp(1f, 1.12f, t); if (_flash != null && _flash.gameObject.activeSelf) _flash.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha * 0.22f); if (_feedbackTime > 0f) return; _feedback.gameObject.SetActive(false); if (_flash != null) _flash.gameObject.SetActive(false); StartNextFeedback(); } private void UpdateCardAnimations() { for (var i = _cardAnimations.Count - 1; i >= 0; i--) { var animation = _cardAnimations[i]; if (animation.Target == null) { _cardAnimations.RemoveAt(i); continue; } animation.Time += Time.unscaledDeltaTime; var t = Mathf.Clamp01(animation.Time / animation.Duration); var eased = 1f - Mathf.Pow(1f - t, 3f); 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 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; _pendingEntries.Add(new CardEntry(target, offset, 0.86f)); } private void PulseCard(int player, int slot, float startScale) { if (!_cardPanels.TryGetValue(CardKey(player, slot), out var target) || target == null) return; 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) { return side.Hand == null || side.Hand.All(item => item.PrintingId != printingId); } 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, 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; } } private void SkipFeedback() { _feedbackQueue.Clear(); _feedbackPlaying = false; _feedbackTime = 0f; _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); } private readonly struct FeedbackStep { public FeedbackStep(string text, Color color, float duration, bool flash) { Text = text; Color = color; Duration = duration; Flash = flash; } public string Text { get; } public Color Color { get; } public float Duration { get; } public bool Flash { get; } } private RectTransform BuildResultPanel(Transform parent) { var overlay = UiKit.CreatePanel(parent, "BattleResult", new Color(0f, 0f, 0f, 0.75f)); var card = UiKit.CreateCenteredCard( overlay, "ResultCard", new Vector2(640f, 330f), new Color(0.08f, 0.10f, 0.14f, 1f)); UiKit.AddVerticalLayout(card, 32, 14); var title = UiKit.CreateText(card, "Title", "Матч завершён", 32, Color.white); title.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(title, 48f); _resultText = UiKit.CreateText(card, "Text", string.Empty, 22, Muted); UiKit.SetPreferredHeight(_resultText, 90f); var exit = UiKit.CreateButton(card, "Exit", "В меню", OnExitClicked, 21); UiKit.SetPreferredHeight(exit, 52f); return overlay; } private async Task LoadCatalogAsync() { if (_catalogLoading || _catalog.Count > 0) return; _catalogLoading = true; try { var items = await _meta.GetCatalogAsync(); foreach (var item in items) _catalog[item.PrintingId] = item; if (_root.gameObject.activeSelf && _view != null) Render(); } catch (Exception exception) { Debug.LogWarning("Battle catalog load failed: " + exception.Message); } finally { _catalogLoading = false; } } private async Task ConnectAsync() { if (_connecting || _info == null) return; _connecting = true; _reconnect.gameObject.SetActive(false); _cts?.Cancel(); _cts?.Dispose(); _cts = new CancellationTokenSource(); try { if (_connection != null) { await _connection.DisposeAsync(); _connection = null; } var connection = new GameConnection(); _connection = connection; connection.HelloReceived += OnHello; connection.SnapshotReceived += OnSnapshot; connection.EventsReceived += OnEvents; connection.ClockReceived += OnClock; connection.ErrorReceived += OnError; connection.Closed += OnClosed; await connection.ConnectAsync( _info.WsUrl, _info.Ticket, Application.version, _view != null ? _view.EventSeq : 0, _cts.Token); SetStatus("Соединение открыто. Ожидаем состояние…", Muted); } catch (OperationCanceledException) { // экран закрыли } catch (Exception exception) { Debug.LogException(exception); SetStatus("Не удалось подключиться: " + exception.Message, new Color(1f, 0.55f, 0.45f)); _reconnect.gameObject.SetActive(true); } finally { _connecting = false; } } private void OnHello(ServerHello hello) { if (_info != null) _info.Seat = hello.PlayerIndex; } private void OnSnapshot(ServerSnapshot snapshot) { if (!_root.gameObject.activeSelf || snapshot?.View == null) return; _previousView = _view; _view = snapshot.View; _finished = _view.IsFinished; _pendingIntents.Clear(); _highlightKeys.Clear(); _triggerPowerSlot = null; PrepareTriggerSelection(); if (_view.Clock != null) { _clockServerMs = _view.Clock.ServerTimeMs; _clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); } Render(); } private void OnEvents(ServerEvents events) { if (events?.Events != null) { foreach (var envelope in events.Events) { var line = Describe(envelope.Event); if (!string.IsNullOrWhiteSpace(line)) _logLines.Add(line); ShowFeedbackFor(envelope.Event); } while (_logLines.Count > 40) _logLines.RemoveAt(0); RebuildLog(); } _ = RequestSnapshotAsync(); } private void RebuildLog() { if (_logContent == null) return; ClearChildren(_logContent); var start = Mathf.Max(0, _logLines.Count - 8); for (var i = start; i < _logLines.Count; i++) { var line = UiKit.CreateText( _logContent, "LogLine", _logLines[i], 13, i == _logLines.Count - 1 ? new Color(0.92f, 0.94f, 1f) : new Color(0.60f, 0.68f, 0.82f), TextAnchor.MiddleLeft); line.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(line, 30f); } } private void ShowFeedbackFor(EventView view) { switch (view) { case AttackDeclaredView attack: ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot), new Color(1f, 0.82f, 0.35f), 1.0f); break; case GuardAddedView guard: ShowFeedback("GUARD +" + guard.Shield, new Color(0.45f, 0.82f, 1f), 1.0f); break; case DriveCheckedView drive: ShowFeedback("DRIVE " + TriggerShort(drive.Trigger.ToString()), new Color(0.72f, 0.88f, 1f), 1.0f); break; case TriggerResolvedView trigger: ShowFeedback("TRIGGER " + trigger.Trigger, new Color(0.72f, 0.88f, 1f), 1.1f); break; case BattleResolvedView battle: ShowFeedback(battle.Hit ? "HIT" : "BLOCK", battle.Hit ? new Color(1f, 0.45f, 0.35f) : new Color(0.45f, 0.82f, 1f), 0.9f); break; case DamageTakenView damage: ShowFeedback("+" + damage.Amount + " DAMAGE", new Color(1f, 0.30f, 0.26f), 1.2f, flash: true); break; case HealedView: ShowFeedback("HEAL", new Color(0.45f, 0.95f, 0.62f), 1.1f, flash: true); break; case UnitRetiredView: ShowFeedback("RETIRE", new Color(1f, 0.55f, 0.30f), 1.0f); break; case PerfectGuardView: ShowFeedback("PERFECT GUARD", new Color(0.60f, 0.82f, 1f), 1.1f); break; } } private string Describe(EventView view) { if (view == null) return null; return view switch { UnitRodeView ride => "Ride: " + CardName(ride.PrintingId), UnitCalledView call => "Call: " + CardName(call.PrintingId) + " → " + SlotName(call.Slot), UnitMovedView move => "Move: " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot), AttackDeclaredView attack => "Атака: " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot), GuardAddedView guard => "Guard: " + CardName(guard.PrintingId) + " (+" + guard.Shield + ")", PerfectGuardView perfect => "Perfect Guard: " + CardName(perfect.SentinelPrintingId), DriveCheckedView drive => "Drive: " + CardName(drive.PrintingId) + " · " + drive.Trigger, TriggerResolvedView trigger => "Триггер: " + CardName(trigger.PrintingId), BattleResolvedView battle => battle.Hit ? "Атака прошла." : "Атака отбита.", DamageTakenView damage => "Урон: " + PlayerName(damage.Player) + " +" + damage.Amount + " (всего " + damage.Total + ")", HealedView heal => "Heal: " + PlayerName(heal.Player), UnitRetiredView retire => "Retire: " + SlotName(retire.Slot), InterceptedView intercept => "Intercept: " + CardName(intercept.PrintingId), CardDrawnView draw => "Карта: " + PlayerName(draw.Player), PhaseChangedView phase => "Фаза: " + phase.Phase, GameFinishedView finish => "Партия завершена.", _ => null, }; } private string PlayerName(int player) => _view == null ? "P" + player : player == _view.You ? "Вы" : "Противник"; private void OnClock(ServerClock clock) { if (!_root.gameObject.activeSelf || clock?.Clock == null) return; _clockServerMs = clock.Clock.ServerTimeMs; _clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (_view != null) _view.Clock = clock.Clock; UpdateClock(); } private void OnError(ServerError error) { SetStatus(error.Code + ": " + error.Message, new Color(1f, 0.55f, 0.45f)); } private void OnClosed(string reason) { if (!_root.gameObject.activeSelf || _finished) return; SetStatus("Соединение потеряно: " + reason, new Color(1f, 0.60f, 0.45f)); _reconnect.gameObject.SetActive(true); } private async Task RequestSnapshotAsync() { if (_snapshotPending || _connection == null || !_connection.IsConnected) return; _snapshotPending = true; try { await _connection.RequestSnapshotAsync(_cts != null ? _cts.Token : CancellationToken.None); } catch (OperationCanceledException) { // экран закрыли } catch (Exception exception) { Debug.LogException(exception); SetStatus("Не удалось обновить состояние.", new Color(1f, 0.60f, 0.45f)); } finally { _snapshotPending = false; } } private async void OnIntentClicked(IntentPayload intent) { if (_sending || _connection == null || _cts == null || _view == null) return; _pendingIntents.Clear(); _highlightKeys.Clear(); _pendingBoosterSlot = -1; _triggerPowerSlot = null; _sending = true; SetStatus("Отправляем: " + Label(intent), Muted); try { var result = await _connection.SubmitAsync(intent, _cts.Token); if (!result.Accepted) { SetStatus( "Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"), new Color(1f, 0.55f, 0.45f)); } else { SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted); await RequestSnapshotAsync(); } } catch (OperationCanceledException) { // экран закрыли } catch (Exception exception) { Debug.LogException(exception); SetStatus("Ошибка отправки интента: " + exception.Message, new Color(1f, 0.55f, 0.45f)); } finally { _sending = false; } } private void OnConcedeClicked() { if (_view == null || _finished) return; if (!_confirmConcede) { _confirmConcede = true; _confirmDeadline = Time.unscaledTime + 4f; _concedeLabel.text = "Подтвердить сдачу"; SetStatus("Нажмите «Подтвердить сдачу» ещё раз.", new Color(1f, 0.76f, 0.38f)); return; } _confirmConcede = false; _concedeLabel.text = "Сдаться"; OnIntentClicked(new ConcedeIntent()); } private async void OnReconnectClicked() { if (_info == null) return; _reconnect.interactable = false; SetStatus("Получаем новый тикет…", Muted); try { var ticket = await _meta.CreateTicketAsync(_info.MatchId); _info.Ticket = ticket.Ticket; _info.WsUrl = ticket.WsUrl; await ConnectAsync(); } catch (ApiException exception) { SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f)); } catch (Exception exception) { Debug.LogException(exception); SetStatus("Переподключение не удалось.", new Color(1f, 0.55f, 0.45f)); } finally { _reconnect.interactable = true; } } private void OnExitClicked() { Hide(); _onExit(); } private void Render() { 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); var self = _view.Players.FirstOrDefault(player => player.Index == _view.You) ?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null); AddSideField(opponent, false); AddGuardZone(_view); AddSideField(self, true); AddHand(self); FinalizeCardEntries(); SpawnRetireGhosts(oldAnchors); if (_view.Battle != null) { PulseCard(_view.Battle.AttackerPlayer, _view.Battle.AttackerSlot, 1.11f); PulseCard(1 - _view.Battle.AttackerPlayer, _view.Battle.TargetSlot, 1.11f); } RebuildIntents(_view); UpdateClock(); if (_view.IsFinished) ShowResult(_view); } private void AddSideField(PlayerView player, bool self) { if (player == null) return; var block = UiKit.CreatePanel( _board, self ? "SelfField" : "OpponentField", new Color(0.05f, 0.07f, 0.11f, 0.96f)); var blockElement = block.gameObject.AddComponent(); blockElement.preferredHeight = 334f; UiKit.AddVerticalLayout(block, 5, 3); var header = UiKit.CreateText( block, "Header", (self ? "ВЫ" : "ПРОТИВНИК") + " · " + player.Name + " рука " + player.HandCount + " · колода " + player.DeckCount + " · урон " + (player.FaceDownDamage + player.FaceUpDamage.Count) + " · soul " + player.Soul.Count + " · drop " + player.Drop.Count, 15, self ? Color.white : new Color(0.94f, 0.82f, 0.70f), TextAnchor.MiddleLeft); header.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(header, 24f); var body = UiKit.CreatePanel(block, "Body", new Color(0f, 0f, 0f, 0f)); var bodyElement = body.gameObject.AddComponent(); bodyElement.flexibleHeight = 1f; bodyElement.minHeight = 294f; UiKit.AddHorizontalLayout(body, 0, 8); if (self) { AddDamageColumn(body, player); AddFieldGrid(body, player, self); AddPileColumn(body, player); } else { AddPileColumn(body, player); AddFieldGrid(body, player, self); AddDamageColumn(body, player); } } private void AddFieldGrid(Transform parent, PlayerView player, bool self) { var grid = UiKit.CreatePanel(parent, "FieldGrid", new Color(0f, 0f, 0f, 0f)); var gridElement = grid.gameObject.AddComponent(); gridElement.flexibleWidth = 1f; gridElement.flexibleHeight = 1f; var layout = grid.gameObject.AddComponent(); layout.cellSize = new Vector2(140f, 140f); layout.spacing = new Vector2(6f, 6f); layout.padding = new RectOffset(4, 4, 4, 4); layout.constraint = GridLayoutGroup.Constraint.FixedColumnCount; layout.constraintCount = 3; layout.childAlignment = TextAnchor.MiddleCenter; layout.startCorner = GridLayoutGroup.Corner.UpperLeft; layout.startAxis = GridLayoutGroup.Axis.Horizontal; // Первый ряд: R V R. Второй ряд: R R R. // Для противника ряды зеркальны: его V-ряд ближе к центру. var slots = self ? new[] { 3, -1, 4, 0, 1, 2 } : new[] { 0, 1, 2, 3, -1, 4 }; foreach (var slot in slots) { var card = CardAt(player.Index, slot); AddCardBox( grid, card, slot == -1 ? "V" : slot <= 2 ? "SUP" + (slot + 1) : "R" + (slot + 1), compact: false, player.Index, slot); } } private void AddDamageColumn(Transform parent, PlayerView player) { var column = UiKit.CreatePanel(parent, "DamageColumn", new Color(0f, 0f, 0f, 0f)); var element = column.gameObject.AddComponent(); element.preferredWidth = 88f; element.minWidth = 80f; element.flexibleWidth = 0f; UiKit.AddVerticalLayout(column, 0, 5); AddPile(column, "УРОН", player.FaceDownDamage + player.FaceUpDamage.Count, new Color(0.48f, 0.12f, 0.12f), 118f); AddPile(column, "SOUL", player.Soul.Count, new Color(0.26f, 0.18f, 0.42f), 96f); } private void AddPileColumn(Transform parent, PlayerView player) { var column = UiKit.CreatePanel(parent, "PileColumn", new Color(0f, 0f, 0f, 0f)); var element = column.gameObject.AddComponent(); element.preferredWidth = 88f; element.minWidth = 80f; element.flexibleWidth = 0f; UiKit.AddVerticalLayout(column, 0, 5); AddPile(column, "КОЛОДА", player.DeckCount, new Color(0.12f, 0.22f, 0.42f), 118f); AddPile(column, "ДРОП", player.Drop.Count, new Color(0.20f, 0.20f, 0.26f), 96f); } private void AddPile(Transform parent, string title, int count, Color color, float height) { var pile = UiKit.CreatePanel(parent, "Pile_" + title, color); var element = pile.gameObject.AddComponent(); element.preferredHeight = height; element.flexibleWidth = 1f; var outline = pile.gameObject.AddComponent(); outline.effectColor = new Color(1f, 1f, 1f, 0.15f); outline.effectDistance = new Vector2(1f, -1f); UiKit.AddVerticalLayout(pile, 5, 1); var label = UiKit.CreateText( pile, "Title", title, 11, new Color(1f, 1f, 1f, 0.75f)); UiKit.SetPreferredHeight(label, 18f); var value = UiKit.CreateText( pile, "Count", count.ToString(), 26, Color.white); value.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(value, 38f); } private void AddGuardZone(MatchView view) { var guard = UiKit.CreatePanel( _board, "GuardZone", new Color(0.08f, 0.11f, 0.18f, 0.98f)); var element = guard.gameObject.AddComponent(); element.preferredHeight = 72f; UiKit.AddHorizontalLayout(guard, 14, 12); var label = UiKit.CreateText( guard, "Label", "ЗОНА ЗАЩИТЫ", 18, new Color(0.82f, 0.88f, 1f), TextAnchor.MiddleLeft); label.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(label, 32f, 0f); var shield = UiKit.CreateText( guard, "Shield", "Щит: " + (view.Battle != null ? view.Battle.GuardShield.ToString() : "—"), 18, new Color(0.55f, 0.86f, 1f), TextAnchor.MiddleCenter); UiKit.SetPreferredHeight(shield, 32f, 0f); var guardian = UiKit.CreateText( guard, "Guardian", "Проверок: " + (view.Battle != null ? view.Battle.ChecksRemaining : 0) + " · защита между полями", 14, Muted, TextAnchor.MiddleRight); UiKit.SetPreferredHeight(guardian, 32f); } private void AddHand(PlayerView self) { var hand = UiKit.CreatePanel(_board, "HandBlock", new Color(0.05f, 0.07f, 0.11f, 0.96f)); var handElement = hand.gameObject.AddComponent(); handElement.preferredHeight = 164f; UiKit.AddVerticalLayout(hand, 6, 3); var header = UiKit.CreateText( hand, "Header", "РУКА · " + (self?.Hand?.Count ?? 0) + " карт", 16, Color.white, TextAnchor.MiddleLeft); header.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(header, 22f); var scroll = UiKit.CreateHorizontalScrollView(hand, "HandScroll", out var content, 6f); var scrollElement = scroll.gameObject.AddComponent(); scrollElement.flexibleHeight = 1f; scrollElement.minHeight = 124f; if (self?.Hand == null || self.Hand.Count == 0) { var empty = UiKit.CreateText( content, "Empty", "Рука пуста", 17, Muted); UiKit.SetPreferredHeight(empty, 110f); return; } foreach (var card in self.Hand) AddCardBox(content, card, "Рука", compact: true, self.Index, -2); } private void AddInfoLine(Transform parent, string text) { var line = UiKit.CreateText( parent, "Info", text, 16, Muted, TextAnchor.MiddleLeft); UiKit.SetPreferredHeight(line, 22f); } private void AddCardBox( Transform parent, CardView card, string slot, bool compact, int playerIndex, int slotIndex) { var meta = card != null && _catalog.TryGetValue(card.PrintingId, out var found) ? found : null; var clanColor = CardArt.ClanColor(meta != null ? meta.Clan : "None"); var frameColor = card == null ? new Color(0.16f, 0.18f, 0.24f) : CardArt.ClanColor(meta != null ? meta.Clan : "None"); var panel = UiKit.CreatePanel( parent, "Card_" + slot, card == null ? new Color(0.06f, 0.08f, 0.12f, 0.9f) : card.FaceUp ? new Color(0.09f, 0.11f, 0.16f, 1f) : new Color(0.05f, 0.08f, 0.14f, 1f)); var element = panel.gameObject.AddComponent(); element.preferredWidth = compact ? 104f : 140f; element.preferredHeight = compact ? 124f : 140f; element.flexibleWidth = 0f; var highlighted = _highlightKeys.Contains( TargetKey(playerIndex, slotIndex, card != null ? card.PrintingId : null)); var button = panel.gameObject.AddComponent