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.Board3D; 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 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 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 int _pendingBoosterSlot = -1; private int? _triggerPowerSlot; private readonly HashSet _mulliganSelected = new HashSet(); private bool _mulliganUsed; private int _abilityDamageIndex = -1; private readonly HashSet _highlightSlots = new HashSet(); private GameBoard3D _board3D; private RectTransform _soulOverlay; private RectTransform _soulContent; private Text _soulTitle; private Text _selfStatsText; private Text _opponentStatsText; public BattleView(Transform parent, MetaService meta, Action onExit) { _meta = meta; _onExit = onExit; _root = UiKit.CreatePanel(parent, "BattleScreen", Background); var rootImage = _root.GetComponent(); if (rootImage != null) { rootImage.color = Color.clear; rootImage.raycastTarget = false; } 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 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(-456f, 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); _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); BuildSoulOverlay(); BuildStatsOverlays(); _board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default); _board3D.ArtProvider = ResolveBoardArt; _board3D.CardClicked += OnBoard3DCardClicked; _board3D.SlotClicked += OnBoard3DSlotClicked; _board3D.CardDropped += OnBoard3DCardDropped; _board3D.PileClicked += OnBoard3DPileClicked; _board3D.PileRightClicked += OnBoard3DPileRightClicked; _board3D.SetVisible(false); _root.gameObject.SetActive(false); } public void Show(MatchConnectionInfo info) { _info = info; _view = null; _finished = false; _confirmConcede = false; _concedeLabel.text = "Сдаться"; _concede.interactable = true; _reconnect.gameObject.SetActive(false); _result.gameObject.SetActive(false); _pendingIntents.Clear(); _highlightSlots.Clear(); _mulliganSelected.Clear(); _mulliganUsed = false; _abilityDamageIndex = -1; _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); _highlightSlots.Clear(); _board3D?.Clear(); _board3D?.SetVisible(true); if (_soulOverlay != null) _soulOverlay.gameObject.SetActive(false); _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; if (_selfStatsText != null) _selfStatsText.text = string.Empty; if (_opponentStatsText != null) _opponentStatsText.text = string.Empty; 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); if (_soulOverlay != null) _soulOverlay.gameObject.SetActive(false); _board3D?.SetVisible(false); _view = null; } public void Tick() { if (_confirmConcede && Time.unscaledTime > _confirmDeadline) { _confirmConcede = false; _concedeLabel.text = "Сдаться"; } UpdateClock(); UpdateFeedback(); } 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 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; _view = snapshot.View; _finished = _view.IsFinished; _pendingIntents.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; if (_abilityDamageIndex >= 0) { if (intent is ActivateAbilityIntent activate) activate.DamageIndex = _abilityDamageIndex; else if (intent is ResolveAbilityIntent resolve) resolve.DamageIndex = _abilityDamageIndex; } _abilityDamageIndex = -1; _pendingIntents.Clear(); _highlightSlots.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)); if (_view != null) Render(); } 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; _board3D?.SetSelfPlayer(_view.You); _board3D?.Render(_view, BuildBoardRenderState()); UpdateStatsOverlay(); RebuildIntents(_view); UpdateClock(); if (_view.IsFinished) ShowResult(_view); } private BoardRenderState BuildBoardRenderState() { var state = new BoardRenderState { MulliganSelecting = IsMulliganSelecting(), DamageChoice = IsDamageChoice(), AbilityDamageTargeting = IsAbilityDamageTargeting(), AbilityDamageIndex = _abilityDamageIndex, MatchFinished = _view != null && _view.IsFinished, }; foreach (var key in _highlightSlots) state.Highlights.Add(key); if (_view != null) { foreach (var intent in _view.LegalIntents ?? new List()) { switch (intent) { case CallIntent call: state.DropTargets.Add(new BoardSlotKey(_view.You, call.Slot)); break; case RideIntent _: state.DropTargets.Add(new BoardSlotKey( _view.You, PlaymatLayout.VanguardSlot)); break; case MoveIntent move: state.DropTargets.Add(new BoardSlotKey(_view.You, move.ToSlot)); break; case AttackIntent attack: state.DropTargets.Add(new BoardSlotKey( 1 - _view.You, attack.TargetSlot)); break; case GuardIntent _: case InterceptIntent _: state.GuardianDropAllowed = true; break; } } } if (state.MulliganSelecting && _view != null && _mulliganSelected.Count > 0) { var hand = _view.Players .FirstOrDefault(player => player.Index == _view.You) ?.Hand; if (hand != null) { foreach (var index in _mulliganSelected) { if (index >= 0 && index < hand.Count) state.SelectedHandInstanceIds.Add(hand[index].InstanceId); } } } return state; } private BoardArt ResolveBoardArt(string printingId) { _catalog.TryGetValue(printingId, out var meta); var clan = meta != null ? meta.Clan : "None"; var nameId = meta != null ? meta.NameId : printingId; var sprite = CardImageService.Resolve(meta != null ? meta.ImageKey : null, null); if (sprite == null) { sprite = CardImageService.Resolve( CardImageService.ImageKeyForPrinting(printingId), null); } if (sprite == null) sprite = CardArt.ForCard(nameId ?? printingId ?? "card", clan); return new BoardArt( sprite != null ? sprite.texture : null, CardArt.ClanColor(clan), meta != null ? Loc.CardName(meta) : printingId); } private bool IsDamageChoice() => _view?.Choice != null && _view.Choice.Prompt != null && _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0; private bool IsAbilityDamageTargeting() { if (_view == null || _view.Choice != null || _view.ActivePlayer != _view.You) return false; var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); if (self == null || self.FaceUpDamage.Count == 0) return false; return (_view.LegalIntents ?? new List()) .Any(intent => intent is ActivateAbilityIntent or ResolveAbilityIntent); } private void OnBoard3DCardClicked(BoardCardRef card) { if (_view == null || _sending) return; switch (card.Zone) { case BoardZone.Hand: OnBoardTargetClicked(new BoardTarget( card.Player, -2, card.PrintingId, card.InstanceId, true)); break; case BoardZone.Vanguard: OnBoardTargetClicked(new BoardTarget( card.Player, -1, card.PrintingId, card.InstanceId, false)); break; case BoardZone.RearGuard: OnBoardTargetClicked(new BoardTarget( card.Player, card.Slot, card.PrintingId, card.InstanceId, false)); break; case BoardZone.Damage: OnDamageClicked(card.Index); break; case BoardZone.Soul: SetStatus("Душа открывается правой кнопкой мыши.", Muted); break; case BoardZone.Guardian: SetStatus("Карта в круге защиты.", Muted); break; } } private void OnBoard3DSlotClicked(BoardCardRef slot) { if (_view == null || _sending) return; if (slot.Zone == BoardZone.Guardian) return; OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false)); } private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target) { if (_view == null || _sending) return; var legal = _view.LegalIntents ?? new List(); IntentPayload intent = null; if (source.Zone == BoardZone.Hand) { if (target.Zone == BoardZone.Guardian) { intent = legal.OfType() .FirstOrDefault(item => item.PrintingId == source.PrintingId); } else if (target.Player == _view.You && target.Zone == BoardZone.Vanguard) { intent = legal.OfType() .FirstOrDefault(item => item.PrintingId == source.PrintingId); } else if (target.Player == _view.You && target.Zone == BoardZone.RearGuard) { intent = legal.OfType() .FirstOrDefault(item => item.PrintingId == source.PrintingId && item.Slot == target.Slot); } } else if (source.IsField) { if (target.Zone == BoardZone.Guardian) { intent = legal.OfType() .FirstOrDefault(item => item.Slot == source.Slot); } else if (target.Player == _view.You && target.Zone == BoardZone.RearGuard) { intent = legal.OfType() .FirstOrDefault(item => item.FromSlot == source.Slot && item.ToSlot == target.Slot); } else if (target.Player != _view.You && (target.Zone == BoardZone.Vanguard || target.Zone == BoardZone.RearGuard)) { intent = legal.OfType() .FirstOrDefault(item => item.AttackerSlot == source.Slot && item.TargetSlot == target.Slot); } } if (intent == null) { SetStatus("Сюда нельзя положить эту карту.", new Color(1f, 0.55f, 0.45f)); RefreshBoardHighlights(); return; } ClearSelection(rebuild: false); OnIntentClicked(intent); } private void OnBoard3DPileRightClicked(BoardCardRef pile) { if (_view == null) return; if (pile.Zone == BoardZone.Soul) ShowSoul(pile.Player); } private void OnBoard3DPileClicked(BoardCardRef pile) { if (_view == null) return; if (pile.Zone == BoardZone.Soul) { SetStatus("Душа открывается правой кнопкой мыши.", Muted); return; } if (pile.Zone == BoardZone.Drop) { ShowDrop(pile.Player); return; } var player = _view.Players.FirstOrDefault(item => item.Index == pile.Player); if (player == null) return; if (pile.Zone == BoardZone.Deck) SetStatus("В колоде " + player.DeckCount + " карт.", Muted); } private void BuildStatsOverlays() { _selfStatsText = CreateStatsPanel( "SelfStats", new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(30f, 24f), TextAnchor.MiddleLeft); _opponentStatsText = CreateStatsPanel( "OpponentStats", new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-488f, -108f), TextAnchor.MiddleRight); } private Text CreateStatsPanel( string name, Vector2 anchor, Vector2 pivot, Vector2 position, TextAnchor alignment) { var panel = UiKit.CreatePanel( _root, name, new Color(0.02f, 0.035f, 0.055f, 0.72f)); panel.anchorMin = anchor; panel.anchorMax = anchor; panel.pivot = pivot; panel.anchoredPosition = position; panel.sizeDelta = new Vector2(360f, 58f); var image = panel.GetComponent(); if (image != null) image.raycastTarget = false; var text = UiKit.CreateText( panel, "Stats", string.Empty, 14, new Color(0.86f, 0.91f, 1f), alignment); UiKit.Stretch(text.rectTransform); text.rectTransform.offsetMin = new Vector2(12f, 5f); text.rectTransform.offsetMax = new Vector2(-12f, -5f); text.horizontalOverflow = HorizontalWrapMode.Wrap; text.verticalOverflow = VerticalWrapMode.Overflow; UiKit.MakeLiteral(text); return text; } private void UpdateStatsOverlay() { if (_view == null || _selfStatsText == null || _opponentStatsText == null) return; var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); var opponent = _view.Players.FirstOrDefault(player => player.Index != _view.You); _selfStatsText.text = FormatStats(self, selfSide: true); _opponentStatsText.text = FormatStats(opponent, selfSide: false); } private static string FormatStats(PlayerView player, bool selfSide) { var title = Loc.Translate(selfSide ? "ВЫ" : "ПРОТИВНИК"); if (player == null) return title; var damage = player.FaceDownDamage + (player.FaceUpDamage?.Count ?? 0); var hand = Mathf.Max(player.HandCount, player.Hand?.Count ?? 0); return title + " · " + player.Name + "\n" + Loc.Translate("Рука") + " " + hand + " " + Loc.Translate("Колода") + " " + player.DeckCount + " " + Loc.Translate("ДРОП") + " " + (player.Drop?.Count ?? 0) + " " + Loc.Translate("УРОН") + " " + damage + " " + Loc.Translate("Душа") + " " + (player.Soul?.Count ?? 0); } private void BuildSoulOverlay() { _soulOverlay = UiKit.CreatePanel(_root, "SoulOverlay", new Color(0f, 0f, 0f, 0.74f)); var panel = UiKit.CreateCenteredCard( _soulOverlay, "SoulPanel", new Vector2(880f, 640f), Panel); UiKit.AddVerticalLayout(panel, 18, 10); _soulTitle = UiKit.CreateText( panel, "SoulTitle", "Душа", 24, Color.white, TextAnchor.MiddleLeft); _soulTitle.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(_soulTitle, 34f); var scroll = UiKit.CreateScrollView(panel, "SoulScroll", out _soulContent, 6f); var scrollElement = scroll.gameObject.AddComponent(); scrollElement.flexibleHeight = 1f; scrollElement.minHeight = 420f; var close = UiKit.CreateButton( panel, "SoulClose", "Закрыть", () => _soulOverlay.gameObject.SetActive(false), 18); UiKit.SetPreferredHeight(close, 46f); _soulOverlay.gameObject.SetActive(false); } private void ShowSoul(int playerIndex) { var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex); if (player == null) return; ShowCardList("Душа", player.Name, player.Soul); } private void ShowDrop(int playerIndex) { var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex); if (player == null) return; ShowCardList("Дроп", player.Name, player.Drop); } private void ShowCardList(string title, string playerName, List cards) { if (_soulOverlay == null) return; _soulTitle.text = title + " · " + playerName + " · " + (cards?.Count ?? 0); ClearChildren(_soulContent); if (cards == null || cards.Count == 0) { var empty = UiKit.CreateText( _soulContent, "CardListEmpty", "Нет карт.", 17, Muted); UiKit.SetPreferredHeight(empty, 80f); } else { for (var i = cards.Count - 1; i >= 0; i--) AddSoulRow(cards[i]); } _soulOverlay.SetAsLastSibling(); _soulOverlay.gameObject.SetActive(true); } private void AddSoulRow(CardView card) { var row = UiKit.CreatePanel( _soulContent, "Soul_" + card.InstanceId, new Color(0.08f, 0.10f, 0.15f, 1f)); var rowElement = row.gameObject.AddComponent(); rowElement.preferredHeight = 106f; UiKit.AddHorizontalLayout(row, 6, 10); var art = UiKit.CreatePanel(row, "Art", Color.white); var artElement = art.gameObject.AddComponent(); artElement.preferredWidth = 64f; artElement.preferredHeight = 92f; artElement.flexibleWidth = 0f; _catalog.TryGetValue(card.PrintingId, out var meta); CardImageService.Apply( art.GetComponent(), meta != null ? meta.ImageKey : null, CardArt.ForCard( meta != null ? meta.NameId : card.PrintingId, meta != null ? meta.Clan : "None")); var name = UiKit.CreateText( row, "Name", meta != null ? Loc.CardName(meta) : card.PrintingId, 17, Color.white, TextAnchor.MiddleLeft); UiKit.MakeLiteral(name); UiKit.SetPreferredHeight(name, 92f); if (meta != null) { var info = UiKit.CreateText( row, "Info", "G" + meta.Grade + " · P " + card.Power + " · C " + card.Critical, 14, Muted, TextAnchor.MiddleRight); UiKit.MakeLiteral(info); UiKit.SetPreferredHeight(info, 92f, 0f); } } private void OnDamageClicked(int displayIndex) { if (IsDamageChoice()) { if (_view?.Choice?.Options == null) return; for (var i = 0; i < _view.Choice.Options.Count; i++) { if (_view.Choice.Options[i].Slot == displayIndex) { OnIntentClicked(new ChooseIntent { Index = i }); return; } } return; } if (!IsAbilityDamageTargeting()) return; _abilityDamageIndex = displayIndex; SetStatus( "Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".", new Color(0.45f, 0.95f, 0.62f)); Render(); } private string CardName(string printingId) => _catalog.TryGetValue(printingId, out var meta) ? Loc.CardName(meta) : printingId; private static string TriggerShort(string trigger) => trigger switch { "Critical" => "K", "Draw" => "D", "Stand" => "S", "Heal" => "H", _ => "—", }; private bool IsMulliganSelecting() => _view != null && !_view.IsFinished && _view.Phase == GamePhase.Mulligan && _view.ActivePlayer == _view.You && !_mulliganUsed; private int HandIndexOf(int instanceId) { var hand = _view?.Players.FirstOrDefault(player => player.Index == _view.You)?.Hand; if (hand == null) return -1; for (var i = 0; i < hand.Count; i++) { if (hand[i].InstanceId == instanceId) return i; } return -1; } private void ToggleMulligan(int index) { if (!_mulliganSelected.Add(index)) _mulliganSelected.Remove(index); SetStatus( _mulliganSelected.Count == 0 ? "Кликните карты, которые вернутся в колоду." : "К обмену выбрано карт: " + _mulliganSelected.Count + ".", new Color(0.98f, 0.82f, 0.42f)); Render(); } private void SendMulligan() { if (!IsMulliganSelecting() || _mulliganSelected.Count == 0) return; var indexes = _mulliganSelected.OrderBy(index => index).ToList(); _mulliganSelected.Clear(); _mulliganUsed = true; OnIntentClicked(new MulliganIntent { HandIndexes = indexes }); } private void OnBoardTargetClicked(BoardTarget target) { if (_view == null || _sending) return; if (IsMulliganSelecting() && target.IsHand && target.Player == _view.You) { var handIndex = HandIndexOf(target.InstanceId); if (handIndex >= 0) { ToggleMulligan(handIndex); return; } } var legal = _view.LegalIntents ?? new List(); if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent) && HandleTriggerTarget(target)) { return; } if (_pendingIntents.Count > 0) { // Клик по своему заднему ряду выбирает карту поддержки. if (target.Player == _view.You && target.Slot is 0 or 1 or 2) { var boosted = _pendingIntents .OfType() .Where(attack => attack.BoosterSlot == target.Slot) .ToList(); if (boosted.Count > 0) { _pendingBoosterSlot = target.Slot; RebuildHighlights(); RebuildIntents(_view); SetStatus( "Поддержка: " + CardName(target.PrintingId) + ". Выберите цель атаки.", new Color(0.98f, 0.82f, 0.42f)); return; } } var matches = _pendingIntents .Where(intent => TryGetTarget(intent, out var candidate) && candidate.Matches(target)) .OrderBy(intent => { if (intent is not AttackIntent attack) return 2; if (_pendingBoosterSlot >= 0) return attack.BoosterSlot == _pendingBoosterSlot ? 0 : 3; return attack.BoosterSlot < 0 ? 0 : 1; }) .ToList(); if (matches.Count > 0) { ClearSelection(rebuild: false); OnIntentClicked(matches[0]); return; } } var sources = legal .Where(intent => TryGetSource(intent, out var source) && source.Matches(target)) .ToList(); if (sources.Count == 0) { SetStatus("Для этой карты сейчас нет доступного действия.", Muted); return; } if (sources.Count == 1) { ClearSelection(rebuild: false); OnIntentClicked(sources[0]); return; } var withTargets = sources .Where(intent => TryGetTarget(intent, out _)) .ToList(); if (withTargets.Count == 0) { ClearSelection(rebuild: false); OnIntentClicked(sources[0]); return; } _pendingIntents.Clear(); _pendingIntents.AddRange(withTargets); RebuildHighlights(); RebuildIntents(_view); SetStatus("Выберите цель на поле.", new Color(0.98f, 0.82f, 0.42f)); } private void ClearSelection(bool rebuild = true) { _pendingIntents.Clear(); _highlightSlots.Clear(); _pendingBoosterSlot = -1; _triggerPowerSlot = null; if (rebuild && _view != null) RebuildIntents(_view); RefreshBoardHighlights(); } private void PrepareTriggerSelection() { if (_view?.Trigger == null || _view.ActivePlayer != _view.You) return; var triggers = (_view.LegalIntents ?? new List()) .OfType() .ToList(); if (triggers.Count == 0) return; _pendingIntents.Clear(); _pendingIntents.AddRange(triggers); _triggerPowerSlot = null; RebuildHighlights(); } private bool HandleTriggerTarget(BoardTarget target) { if (_view?.Trigger == null || target.Player != _view.You) return false; var triggers = _pendingIntents.OfType().ToList(); if (triggers.Count == 0) return false; if (_triggerPowerSlot == null) { var powerTargets = triggers .Where(trigger => trigger.PowerTarget == target.Slot) .ToList(); if (powerTargets.Count == 0) return false; _triggerPowerSlot = target.Slot; _pendingIntents.Clear(); _pendingIntents.AddRange(powerTargets); RebuildHighlights(); RebuildIntents(_view); SetStatus( "Триггер: выберите цель эффекта.", new Color(0.98f, 0.82f, 0.42f)); return true; } var effect = triggers.FirstOrDefault(trigger => trigger.EffectTarget == target.Slot); if (effect == null) return false; ClearSelection(rebuild: false); OnIntentClicked(effect); return true; } private void RebuildHighlights() { _highlightSlots.Clear(); if (_view == null) return; if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent)) { foreach (var trigger in _pendingIntents.OfType()) { var slot = _triggerPowerSlot == null ? trigger.PowerTarget : trigger.EffectTarget; _highlightSlots.Add(new BoardSlotKey(_view.You, slot)); } RefreshBoardHighlights(); return; } foreach (var intent in _pendingIntents) { if (TryGetTarget(intent, out var target) && target.Slot >= -1) _highlightSlots.Add(new BoardSlotKey(target.Player, target.Slot)); if (intent is AttackIntent attack && attack.BoosterSlot >= 0) _highlightSlots.Add(new BoardSlotKey(_view.You, attack.BoosterSlot)); } RefreshBoardHighlights(); } private void RefreshBoardHighlights() { if (_view != null && _board3D != null) _board3D.Render(_view, BuildBoardRenderState()); } private bool TryGetSource(IntentPayload intent, out BoardTarget source) { source = default; if (_view == null) return false; switch (intent) { case RideIntent ride: source = new BoardTarget(_view.You, -2, ride.PrintingId, 0, true); return true; case CallIntent call: source = new BoardTarget(_view.You, -2, call.PrintingId, 0, true); return true; case GuardIntent guard: source = new BoardTarget(_view.You, -2, guard.PrintingId, 0, true); return true; case MoveIntent move: source = FieldTarget(_view.You, move.FromSlot); return true; case AttackIntent attack: source = FieldTarget(_view.You, attack.AttackerSlot); return true; case InterceptIntent intercept: source = FieldTarget(_view.You, intercept.Slot); return true; case ActivateAbilityIntent activate: source = InstanceTarget(_view.You, activate.InstanceId); return source.InstanceId != 0; default: return false; } } private bool TryGetTarget(IntentPayload intent, out BoardTarget target) { target = default; if (_view == null) return false; switch (intent) { case CallIntent call: target = FieldTarget(_view.You, call.Slot); return true; case MoveIntent move: target = FieldTarget(_view.You, move.ToSlot); return true; case AttackIntent attack: target = FieldTarget(1 - _view.You, attack.TargetSlot); return true; default: return false; } } private BoardTarget FieldTarget(int player, int slot) { var card = CardAt(player, slot); return new BoardTarget(player, slot, card != null ? card.PrintingId : null, card != null ? card.InstanceId : 0, false); } private BoardTarget InstanceTarget(int player, int instanceId) { var card = InstanceAt(player, instanceId); if (card == null) return default; if (_view != null && _view.Players.FirstOrDefault(item => item.Index == player)?.Vanguard is { } vanguard && vanguard.InstanceId == instanceId) { return new BoardTarget(player, -1, card.PrintingId, instanceId, false); } var field = _view?.Players.FirstOrDefault(item => item.Index == player); if (field?.RearGuards != null) { for (var i = 0; i < field.RearGuards.Length; i++) { if (field.RearGuards[i]?.InstanceId == instanceId) return new BoardTarget(player, i, card.PrintingId, instanceId, false); } } return default; } private CardView CardAt(int player, int slot) { var side = _view?.Players.FirstOrDefault(item => item.Index == player); if (side == null) return null; if (slot == -1) return side.Vanguard; return side.RearGuards != null && slot >= 0 && slot < side.RearGuards.Length ? side.RearGuards[slot] : null; } private CardView InstanceAt(int player, int instanceId) { var side = _view?.Players.FirstOrDefault(item => item.Index == player); if (side == null) return null; if (side.Vanguard?.InstanceId == instanceId) return side.Vanguard; return side.RearGuards?.FirstOrDefault(card => card?.InstanceId == instanceId); } private bool IsBoardIntent(IntentPayload intent) => intent is RideIntent or CallIntent or MoveIntent or AttackIntent or GuardIntent or InterceptIntent or ActivateAbilityIntent; private void RebuildIntents(MatchView view) { ClearChildren(_intents); var legal = view.LegalIntents ?? new List(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); var board = playable.Where(IsBoardIntent).ToList(); var global = playable.Where(intent => !IsBoardIntent(intent)).ToList(); if (_pendingIntents.Count > 0) { var prompt = UiKit.CreateText( _intents, "TargetPrompt", _pendingIntents.Any(intent => intent is ResolveTriggerIntent) ? (_triggerPowerSlot == null ? "Триггер: выберите цель силы." : "Триггер: выберите цель эффекта.") : _pendingBoosterSlot >= 0 ? "Поддержка: " + CardName( CardAt(_view.You, _pendingBoosterSlot)?.PrintingId ?? string.Empty) + ".\nВыберите цель атаки или «Отмена»." : "Выберите цель на поле.\nНажмите «Отмена», чтобы снять выбор.", 16, new Color(0.98f, 0.82f, 0.42f), TextAnchor.UpperLeft); prompt.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(prompt, 64f); var cancel = UiKit.CreateButton( _intents, "CancelTarget", "Отмена", () => { ClearSelection(); Render(); }, 17); UiKit.SetPreferredHeight(cancel, 44f); return; } if (playable.Count == 0) { var waiting = UiKit.CreateText( _intents, "Waiting", view.IsFinished ? "Матч завершён." : "Сейчас решает противник…", 18, Muted); UiKit.SetPreferredHeight(waiting, 110f); return; } if (IsMulliganSelecting()) { var mulliganHint = UiKit.CreateText( _intents, "MulliganHint", "Кликните карты, которые вернутся в колоду.", 15, new Color(0.72f, 0.80f, 0.94f), TextAnchor.UpperLeft); mulliganHint.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(mulliganHint, 34f); if (_mulliganSelected.Count > 0) { var exchange = UiKit.CreateButton( _intents, "MulliganExchange", "Маллиган: " + _mulliganSelected.Count + " карт", SendMulligan, 16); UiKit.SetPreferredHeight(exchange, 44f); } } if (IsAbilityDamageTargeting()) { var abilityHint = UiKit.CreateText( _intents, "AbilityDamageHint", "Выберите закрытую карту урона для Counter Blast (необязательно).", 15, new Color(0.72f, 0.80f, 0.94f), TextAnchor.UpperLeft); abilityHint.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(abilityHint, 34f); } if (board.Count > 0) { var hint = UiKit.CreateText( _intents, "BoardHint", "Нажмите на карту в руке или на поле.\nДопустимые цели подсветятся.", 15, new Color(0.72f, 0.80f, 0.94f), TextAnchor.UpperLeft); hint.horizontalOverflow = HorizontalWrapMode.Wrap; UiKit.SetPreferredHeight(hint, 52f); var toggle = UiKit.CreateButton( _intents, "ToggleAllIntents", _showAllIntents ? "Скрыть все действия" : "Показать все действия", () => { _showAllIntents = !_showAllIntents; RebuildIntents(_view); }, 15); UiKit.SetPreferredHeight(toggle, 38f); } foreach (var intent in global) AddIntentButton(intent); if (_showAllIntents) { foreach (var intent in board) AddIntentButton(intent); } } private void AddIntentButton(IntentPayload intent) { var captured = intent; var button = UiKit.CreateButton( _intents, "Intent_" + captured.GetType().Name, Label(captured), () => OnIntentClicked(captured), 16); UiKit.SetPreferredHeight(button, 46f); } private void ShowResult(MatchView view) { if (_result.gameObject.activeSelf) return; _result.SetAsLastSibling(); _result.gameObject.SetActive(true); _concede.interactable = false; _resultText.text = view.Winner < 0 ? "Ничья или матч прерван." : view.Winner == view.You ? "Победа!" : "Поражение."; _resultText.text += "\nМатч " + view.MatchId + " завершён."; } private void UpdateClock() { if (_view?.Clock == null || !_view.Clock.IsRunning) { _clock.text = "—"; return; } var localNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var serverNow = _clockServerMs + (localNow - _clockReceivedMs); var remaining = Math.Max(0, _view.Clock.TurnDeadlineMs - serverNow); var bank = _view.Clock.DecisionPlayer == _view.You ? _view.Clock.Bank0Ms : _view.Clock.Bank1Ms; _clock.text = Format(remaining) + " · банк " + Format(bank); } private static string Format(long milliseconds) { var totalSeconds = Math.Max(0, milliseconds / 1000); var minutes = totalSeconds / 60; var seconds = totalSeconds % 60; return minutes.ToString("00") + ":" + seconds.ToString("00"); } private static void ClearChildren(Transform parent) { for (var i = parent.childCount - 1; i >= 0; i--) { var child = parent.GetChild(i).gameObject; if (Application.isPlaying) UnityEngine.Object.Destroy(child); else UnityEngine.Object.DestroyImmediate(child); } } private static string SlotName(int slot) { if (slot < 0) return "Vanguard"; return "R" + (slot + 1); } private string Label(IntentPayload intent) { switch (intent) { case FinishMulliganIntent _: return "Закончить маллиган"; case MulliganIntent mulligan: return "Маллиган: " + mulligan.HandIndexes.Count + " карт"; case SkipRideIntent _: return "Пропустить Ride"; case RideIntent ride: return "Ride: " + CardName(ride.PrintingId); case CallIntent call: return "Call " + CardName(call.PrintingId) + " → " + SlotName(call.Slot); case MoveIntent move: return "Move " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot); case EnterBattleIntent _: return "В фазу боя"; case AttackIntent attack: return "Атака " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot) + (attack.BoosterSlot >= 0 ? " + boost" : string.Empty); case GuardIntent guard: return "Guard: " + CardName(guard.PrintingId); case InterceptIntent intercept: return "Intercept: " + SlotName(intercept.Slot); case PassGuardIntent _: return "Пропустить Guard"; case ResolveTriggerIntent trigger: return "Триггер: сила " + SlotName(trigger.PowerTarget) + ", эффект " + SlotName(trigger.EffectTarget); case SelectTriggerIntent select: return "Выбрать триггер #" + select.Index; case ResolveAbilityIntent ability: return ability.Accept ? "Применить способность" : "Пропустить способность"; case ChooseIntent choose: return ChoiceLabel(choose.Index); case ActivateAbilityIntent activate: return "Способность #" + activate.InstanceId; case EndTurnIntent _: return "Закончить ход"; case ConcedeIntent _: return "Сдаться"; default: return intent.GetType().Name; } } private string ChoiceLabel(int index) { if (_view?.Choice?.Options != null && index >= 0 && index < _view.Choice.Options.Count) { return "Выбрать: " + _view.Choice.Options[index].Label; } return "Выбрать вариант #" + index; } private void SetStatus(string text, Color color) { _status.text = text ?? string.Empty; _status.color = color; } private readonly struct BoardTarget { public BoardTarget( int player, int slot, string printingId, int instanceId, bool isHand) { Player = player; Slot = slot; PrintingId = printingId; InstanceId = instanceId; IsHand = isHand; } public int Player { get; } public int Slot { get; } public string PrintingId { get; } public int InstanceId { get; } public bool IsHand { get; } public bool Matches(BoardTarget other) { if (IsHand || other.IsHand) { return IsHand && other.IsHand && Player == other.Player && string.Equals(PrintingId, other.PrintingId, StringComparison.Ordinal); } return Player == other.Player && Slot == other.Slot; } } } }