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; using Vanguard.UI.Overlays; 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 AnimationConfig _animationConfig; private readonly InputLockService _inputLock; private bool _feedbackLockHeld; 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 OverlayNavigationService _overlays; private ZoneBlockStripView _selfZones; private ZoneBlockStripView _opponentZones; private CardActionMenuView _cardMenu; private RectTransform _intentPanel; private Text _intentPanelToggleLabel; private bool _intentPanelVisible = true; private RectTransform _phasePanel; private RectTransform _combatStats; private Text _combatStatsText; private bool _autoFinishMulligan; private float _autoFinishMulliganAt; private bool _autoEndBattleArmed; private float _autoEndBattleAt; public BattleView(Transform parent, MetaService meta, Action onExit) { _meta = meta; _onExit = onExit; _animationConfig = AnimationConfig.LoadOrDefault(); _inputLock = new InputLockService(); _inputLock.Changed += OnInputLockChanged; _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); var toggle = UiKit.CreateButton( top, "ToggleIntentPanel", "Скрыть панель", ToggleIntentPanel, 16); var toggleElement = UiKit.SetPreferredHeight(toggle, 44f, 0f); toggleElement.preferredWidth = 168f; _intentPanelToggleLabel = toggle.GetComponentInChildren(); UiKit.MakeLiteral(_intentPanelToggleLabel); _concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18); _concedeLabel = _concede.GetComponentInChildren(); UiKit.SetPreferredHeight(_concede, 44f, 0f); var right = UiKit.CreatePanel(_root, "IntentPanel", Panel); _intentPanel = right; 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); BuildOverlaysAndZones(); BuildPhasePanel(); BuildCombatStats(); _cardMenu = new CardActionMenuView(); _cardMenu.Build(_root); _board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default); _board3D.ConfigureRuntime(_animationConfig, _inputLock); _board3D.ArtProvider = ResolveBoardArt; _board3D.CardClicked += OnBoard3DCardClicked; _board3D.CardRightClicked += OnBoard3DCardRightClicked; _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; _autoFinishMulligan = false; _autoFinishMulliganAt = 0f; _autoEndBattleArmed = false; _autoEndBattleAt = 0f; _intentPanelVisible = true; if (_intentPanel != null) _intentPanel.gameObject.SetActive(true); if (_intentPanelToggleLabel != null) { _intentPanelToggleLabel.text = "Скрыть панель"; UiKit.MakeLiteral(_intentPanelToggleLabel); } _inputLock.ReleaseAll(); _feedbackLockHeld = false; _logLines.Clear(); RebuildLog(); _feedbackQueue.Clear(); _feedbackPlaying = false; _feedbackTime = 0f; _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); _combatStats?.gameObject.SetActive(false); _board3D?.Clear(); _board3D?.SetVisible(true); _overlays?.CloseAll(); _selfZones?.Update(null, self: true); _opponentZones?.Update(null, self: false); _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(); } _view = null; _root.gameObject.SetActive(false); _result.gameObject.SetActive(false); _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); _overlays?.CloseAll(); _cardMenu?.Hide(); _combatStats?.gameObject.SetActive(false); _board3D?.SetVisible(false); ReleaseFeedbackLock(); _inputLock.ReleaseAll(); } public void Tick() { if (_confirmConcede && Time.unscaledTime > _confirmDeadline) { _confirmConcede = false; _concedeLabel.text = "Сдаться"; } UpdateClock(); UpdateFeedback(); UpdateAutoPhases(); } private void UpdateAutoPhases() { if (_view == null || _sending || _inputLock.IsLocked) return; var legal = _view.LegalIntents ?? new List(); if (_autoFinishMulligan) { if (_view.Phase != GamePhase.Mulligan) { _autoFinishMulligan = false; _autoFinishMulliganAt = 0f; } else if (_view.ActivePlayer == _view.You) { var finish = legal.OfType().FirstOrDefault(); if (finish != null) { if (_autoFinishMulliganAt <= 0f) { _autoFinishMulliganAt = Time.unscaledTime + 0.6f; } else if (Time.unscaledTime >= _autoFinishMulliganAt) { _autoFinishMulligan = false; _autoFinishMulliganAt = 0f; OnIntentClicked(finish); } } } } var battle = _view.Phase == GamePhase.Battle && _view.ActivePlayer == _view.You && !_view.IsFinished; var actionable = legal.Any(intent => intent is AttackIntent or ActivateAbilityIntent or ResolveAbilityIntent or ResolveTriggerIntent or SelectTriggerIntent or ChooseIntent); var endTurn = legal.OfType().FirstOrDefault(); if (battle && !actionable && endTurn != null && _pendingIntents.Count == 0) { if (!_autoEndBattleArmed) { _autoEndBattleArmed = true; _autoEndBattleAt = Time.unscaledTime + 1.0f; } else if (Time.unscaledTime >= _autoEndBattleAt && !_feedbackPlaying) { _autoEndBattleArmed = false; OnIntentClicked(endTurn); } } else { _autoEndBattleArmed = false; } } 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); ReleaseFeedbackLock(); return; } if (!_feedbackLockHeld) { _feedbackLockHeld = true; _inputLock.Acquire("feedback"); } 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 ReleaseFeedbackLock() { if (!_feedbackLockHeld) return; _feedbackLockHeld = false; _inputLock.Release("feedback"); } private void OnInputLockChanged(bool locked) { if (_view == null) return; RebuildIntents(_view); RebuildPhaseControls(_view); } private void OnOverlayOpenChanged(bool open) { if (open) _inputLock.Acquire("overlay"); else _inputLock.Release("overlay"); } 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); ReleaseFeedbackLock(); } 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), _animationConfig.attackDeclare); break; case GuardAddedView guard: ShowFeedback("GUARD " + CardName(guard.PrintingId) + " +" + guard.Shield, new Color(0.45f, 0.82f, 1f), _animationConfig.guard); break; case DriveCheckedView drive: ShowFeedback("DRIVE " + CardName(drive.PrintingId) + " · " + TriggerShort(drive.Trigger.ToString()), new Color(0.72f, 0.88f, 1f), _animationConfig.driveCheck); break; case TriggerResolvedView trigger: ShowFeedback("TRIGGER " + CardName(trigger.PrintingId), new Color(0.72f, 0.88f, 1f), _animationConfig.trigger); 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), _animationConfig.battleResolved); break; case DamageTakenView damage: ShowFeedback("+" + damage.Amount + " DAMAGE", new Color(1f, 0.30f, 0.26f), _animationConfig.damage, flash: true); break; case HealedView: ShowFeedback("HEAL", new Color(0.45f, 0.95f, 0.62f), _animationConfig.heal, flash: true); break; case UnitRetiredView: ShowFeedback("RETIRE", new Color(1f, 0.55f, 0.30f), _animationConfig.retireDuration); break; case InterceptedView intercept: ShowFeedback("INTERCEPT " + CardName(intercept.PrintingId), new Color(0.55f, 0.82f, 1f), _animationConfig.intercept); break; case PerfectGuardView perfect: ShowFeedback("PERFECT GUARD " + CardName(perfect.SentinelPrintingId), new Color(0.60f, 0.82f, 1f), _animationConfig.perfectGuard); 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 (_inputLock.IsLocked && intent is not ConcedeIntent) 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; if (intent is FinishMulliganIntent or SkipRideIntent or EnterBattleIntent or EndTurnIntent) { _autoFinishMulligan = false; _autoFinishMulliganAt = 0f; _autoEndBattleArmed = false; } _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 ToggleIntentPanel() { _intentPanelVisible = !_intentPanelVisible; if (_intentPanel != null) _intentPanel.gameObject.SetActive(_intentPanelVisible); if (_intentPanelToggleLabel != null) { _intentPanelToggleLabel.text = _intentPanelVisible ? "Скрыть панель" : "Показать панель"; UiKit.MakeLiteral(_intentPanelToggleLabel); } } 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; _cardMenu?.Hide(); _board3D?.SetSelfPlayer(_view.You); _board3D?.Render(_view, BuildBoardRenderState()); UpdateCombatStats(); UpdateZoneStrips(); RebuildIntents(_view); RebuildPhaseControls(_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 || _inputLock.IsLocked) return; var targetSelection = _pendingIntents.Count > 0 || IsMulliganSelecting() || IsDamageChoice() || IsAbilityDamageTargeting() || _view.Trigger != null || _view.Choice != null || _view.Ability != null || _view.Order != null; switch (card.Zone) { case BoardZone.Hand: if (card.Player != _view.You) { OpenZone(BoardZone.Hand, card.Player); break; } OnBoardTargetClicked(new BoardTarget( card.Player, -2, card.PrintingId, card.InstanceId, true)); break; case BoardZone.Vanguard: case BoardZone.RearGuard: if (targetSelection) { OnBoardTargetClicked(new BoardTarget( card.Player, card.Zone == BoardZone.Vanguard ? -1 : card.Slot, card.PrintingId, card.InstanceId, false)); } else if (card.Player == _view.You) { OpenCardMenu(card); } else { OpenCardDetails(card); } break; case BoardZone.Damage: OnDamageClicked(card.Index); break; case BoardZone.Soul: SetStatus("Душа открывается правой кнопкой мыши.", Muted); break; case BoardZone.Guardian: case BoardZone.Trigger: OpenCardDetails(card); break; } } private void OpenCardMenu(BoardCardRef card) { if (_view == null || _board3D == null) return; var target = new BoardTarget( card.Player, card.Zone == BoardZone.Vanguard ? -1 : card.Slot, card.PrintingId, card.InstanceId, false); var legal = _view.LegalIntents ?? new List(); var sources = legal .Where(intent => TryGetSource(intent, out var source) && source.Matches(target)) .ToList(); var actions = new List(); var attacks = sources.OfType().ToList(); foreach (var group in attacks.GroupBy(attack => attack.BoosterSlot)) { var pending = group.ToList(); var booster = group.Key; var label = booster < 0 ? "Атака" : "Атака с поддержкой: " + CardName( CardAt(_view.You, booster)?.PrintingId ?? string.Empty); actions.Add(new CardActionMenuItem(label, () => { BeginTargetSelection(pending, "Выберите цель атаки."); })); } var moves = sources.OfType().ToList(); if (moves.Count > 0) { actions.Add(new CardActionMenuItem("Переместить", () => { BeginTargetSelection(moves, "Выберите круг для перемещения."); })); } var abilities = sources.OfType().ToList(); if (abilities.Count > 0) { actions.Add(new CardActionMenuItem( "Активировать способность", () => OpenAbilitySelector(abilities))); } foreach (var intent in sources) { if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent) continue; var captured = intent; actions.Add(new CardActionMenuItem( Label(captured), () => OnIntentClicked(captured))); } actions.Add(new CardActionMenuItem("Подробнее", () => OpenCardDetails(card))); if (!_board3D.TryGetCardScreenPosition(card, out var screen)) return; _inputLock.Acquire("card-menu"); _cardMenu.Show( screen, CardName(card.PrintingId), actions, () => _inputLock.Release("card-menu")); } private void BeginTargetSelection( IEnumerable intents, string prompt) { var list = intents.ToList(); if (list.Count == 0) return; _pendingIntents.Clear(); _pendingIntents.AddRange(list); RebuildHighlights(); RebuildIntents(_view); SetStatus(prompt, new Color(0.98f, 0.82f, 0.42f)); } private void OpenCardDetails(BoardCardRef card) { if (_view == null || _overlays == null) return; var view = FindCardView(card); var entry = view != null ? new CardListEntry(view) : new CardListEntry(null, true, "Закрытая карта"); _overlays.OpenCardDetail(entry); } private CardView FindCardView(BoardCardRef card) { if (_view == null) return null; var player = _view.Players.FirstOrDefault(item => item.Index == card.Player); if (player == null) return null; switch (card.Zone) { case BoardZone.Vanguard: return player.Vanguard; case BoardZone.RearGuard: return player.RearGuards != null && card.Slot >= 0 && card.Slot < player.RearGuards.Length ? player.RearGuards[card.Slot] : null; case BoardZone.Hand: return player.Hand?.FirstOrDefault(item => item.InstanceId == card.InstanceId); case BoardZone.Guardian: return player.Guardian?.FirstOrDefault(item => item.InstanceId == card.InstanceId); case BoardZone.Trigger: return player.Trigger?.FirstOrDefault(item => item.InstanceId == card.InstanceId); case BoardZone.Damage: return player.FaceUpDamage != null && card.Index >= 0 && card.Index < player.FaceUpDamage.Count ? player.FaceUpDamage[card.Index] : null; default: return null; } } private void OnBoard3DSlotClicked(BoardCardRef slot) { if (_view == null || _sending || _inputLock.IsLocked) return; if (slot.Zone == BoardZone.Guardian) return; OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false)); } private void AddAbilityButton(IReadOnlyList abilities) { if (abilities == null || abilities.Count == 0) return; var button = UiKit.CreateButton( _intents, "ActivateAbility", "Активировать способность", () => OpenAbilitySelector(abilities), 16); button.interactable = !_inputLock.IsLocked; UiKit.SetPreferredHeight(button, 46f); } private void AddRideButtons(IReadOnlyList rides) { foreach (var ride in rides .GroupBy(item => item.PrintingId) .Select(group => group.First())) { var captured = ride; var button = UiKit.CreateButton( _intents, "Ride_" + captured.PrintingId, "Сменить: " + CardName(captured.PrintingId), () => OnIntentClicked(captured), 16); button.interactable = !_inputLock.IsLocked; UiKit.SetPreferredHeight(button, 46f); } } private void AddGuardButton(IReadOnlyList guards) { if (guards == null || guards.Count == 0) return; var button = UiKit.CreateButton( _intents, "GuardAction", "Защититься", () => OpenGuardSelector(guards), 16); button.interactable = !_inputLock.IsLocked; UiKit.SetPreferredHeight(button, 46f); } private void OpenGuardSelector(IReadOnlyList guards) { var groups = guards .GroupBy(guard => guard.PrintingId) .ToList(); if (groups.Count == 0) return; if (groups.Count == 1) { var variants = groups[0].ToList(); if (variants.Count > 1) ShowGuardDiscardChoice(variants); else OnIntentClicked(variants[0]); return; } var items = groups .Select(group => new CardActionMenuItem( "Защититься: " + CardName(group.Key), () => { var variants = group.ToList(); if (variants.Count > 1) ShowGuardDiscardChoice(variants); else OnIntentClicked(variants[0]); })) .ToList(); ShowChoiceMenu("Защита", items); } private void OpenAbilitySelector(IReadOnlyList abilities) { var groups = abilities .GroupBy(ability => ability.InstanceId) .ToList(); if (groups.Count == 0) return; if (groups.Count == 1) { ShowAbilityVariantChoice(groups[0].ToList()); return; } var items = groups .Select(group => new CardActionMenuItem( "Активировать: " + CardNameForInstance(group.Key), () => ShowAbilityVariantChoice(group.ToList()))) .ToList(); ShowChoiceMenu("Способности", items); } private void ShowAbilityVariantChoice( IReadOnlyList variants) { var distinct = variants .GroupBy(variant => variant.DiscardPrintingId ?? string.Empty) .Select(group => group.First()) .ToList(); if (distinct.Count == 0) return; var free = distinct.FirstOrDefault( variant => string.IsNullOrEmpty(variant.DiscardPrintingId)); var discardVariants = distinct .Where(variant => !string.IsNullOrEmpty(variant.DiscardPrintingId)) .ToList(); if (discardVariants.Count == 0) { OnIntentClicked(free ?? distinct[0]); return; } var items = new List(); if (free != null) items.Add(new CardActionMenuItem("Без сброса", () => OnIntentClicked(free))); var hand = PlayerHand(); foreach (var card in hand .GroupBy(item => item.PrintingId) .Select(group => group.First())) { var variant = discardVariants.FirstOrDefault( item => item.DiscardPrintingId == card.PrintingId); if (variant == null) continue; items.Add(new CardActionMenuItem( "Сбросить: " + CardName(card.PrintingId), () => OnIntentClicked(variant))); } if (items.Count == 0) { items.AddRange(discardVariants.Select(variant => new CardActionMenuItem( "Сбросить: " + CardName(variant.DiscardPrintingId), () => OnIntentClicked(variant)))); } ShowChoiceMenu("Сброс для способности", items); } private void ShowGuardDiscardChoice( IReadOnlyList guards, Action onClosed = null) { var distinct = guards .GroupBy(guard => guard.DiscardPrintingId ?? string.Empty) .Select(group => group.First()) .ToList(); if (distinct.Count == 0) return; var discardVariants = distinct .Where(guard => !string.IsNullOrEmpty(guard.DiscardPrintingId)) .ToList(); if (discardVariants.Count == 0) { OnIntentClicked(distinct[0]); return; } var items = new List(); var hand = PlayerHand(); foreach (var card in hand .GroupBy(item => item.PrintingId) .Select(group => group.First())) { var variant = discardVariants.FirstOrDefault( guard => guard.DiscardPrintingId == card.PrintingId); if (variant == null) continue; items.Add(new CardActionMenuItem( "Perfect Guard: сбросить " + CardName(card.PrintingId), () => OnIntentClicked(variant))); } if (items.Count == 0) { items.AddRange(discardVariants.Select(guard => new CardActionMenuItem( "Perfect Guard: сбросить " + CardName(guard.DiscardPrintingId), () => OnIntentClicked(guard)))); } ShowChoiceMenu("Guard", items, onClosed); } private List PlayerHand() { var player = _view?.Players.FirstOrDefault(item => item.Index == _view.You); return player?.Hand ?? new List(); } private void ShowChoiceMenu( string title, IReadOnlyList items, Action onClosed = null) { if (_cardMenu == null || items == null || items.Count == 0) return; _inputLock.Acquire("card-menu"); _cardMenu.Show( new Vector2(Screen.width * 0.5f, Screen.height * 0.5f), title, items, () => { _inputLock.Release("card-menu"); onClosed?.Invoke(); }); } private string CardNameForInstance(int instanceId) { if (_view == null) return "карта #" + instanceId; foreach (var player in _view.Players) { var cards = new List(); if (player.Vanguard != null) cards.Add(player.Vanguard); if (player.RearGuards != null) { foreach (var card in player.RearGuards) { if (card != null) cards.Add(card); } } if (player.Hand != null) cards.AddRange(player.Hand); if (player.Drop != null) cards.AddRange(player.Drop); if (player.Soul != null) cards.AddRange(player.Soul); if (player.Guardian != null) cards.AddRange(player.Guardian); var found = cards.FirstOrDefault(card => card.InstanceId == instanceId); if (found != null) return CardName(found.PrintingId); } return "карта #" + instanceId; } private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target) { if (_view == null || _sending || _inputLock.IsLocked) return; var legal = _view.LegalIntents ?? new List(); IntentPayload intent = null; if (source.Zone == BoardZone.Hand) { if (target.Zone == BoardZone.Guardian) { var guards = legal.OfType() .Where(item => item.PrintingId == source.PrintingId) .ToList(); if (guards.Count > 1) { ShowGuardDiscardChoice(guards, Render); return; } intent = guards.FirstOrDefault(); } 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 || _inputLock.IsLocked) return; if (pile.Zone == BoardZone.Soul) OpenZone(BoardZone.Soul, pile.Player); } private void OnBoard3DCardRightClicked(BoardCardRef card) { if (_view == null || _inputLock.IsLocked) return; if (card.Zone == BoardZone.Vanguard) OpenZone(BoardZone.Soul, card.Player); } private void OnBoard3DPileClicked(BoardCardRef pile) { if (_view == null || _inputLock.IsLocked) return; if (pile.Zone == BoardZone.Soul) { SetStatus("Душа открывается правой кнопкой мыши по VC.", Muted); return; } if (pile.Zone == BoardZone.Drop) { OpenZone(BoardZone.Drop, pile.Player); return; } if (pile.Zone == BoardZone.Deck) OpenZone(BoardZone.Deck, pile.Player); } private void BuildPhasePanel() { _phasePanel = UiKit.CreatePanel( _root, "PhasePanel", new Color(0.02f, 0.035f, 0.055f, 0.84f)); _phasePanel.anchorMin = new Vector2(1f, 0.5f); _phasePanel.anchorMax = new Vector2(1f, 0.5f); _phasePanel.pivot = new Vector2(1f, 0.5f); _phasePanel.anchoredPosition = new Vector2(-468f, 0f); _phasePanel.sizeDelta = new Vector2(156f, 188f); UiKit.AddVerticalLayout(_phasePanel, 8, 6); _phasePanel.gameObject.SetActive(false); } private void RebuildPhaseControls(MatchView view) { if (_phasePanel == null) return; ClearChildren(_phasePanel); if (view == null || _pendingIntents.Count > 0) { _phasePanel.gameObject.SetActive(false); return; } var intents = (view.LegalIntents ?? new List()) .Where(IsPhaseIntent) .Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1)) .ToList(); if (intents.Count == 0) { _phasePanel.gameObject.SetActive(false); return; } _phasePanel.gameObject.SetActive(true); var title = UiKit.CreateText( _phasePanel, "PhaseTitle", "ФАЗЫ", 14, new Color(0.72f, 0.80f, 0.94f)); title.fontStyle = FontStyle.Bold; UiKit.SetPreferredHeight(title, 22f); foreach (var intent in intents) { var captured = intent; var button = UiKit.CreateButton( _phasePanel, "Phase_" + captured.GetType().Name, PhaseLabel(captured), () => OnIntentClicked(captured), 14); button.interactable = !_inputLock.IsLocked; UiKit.SetPreferredHeight(button, 40f); } } private static bool IsPhaseIntent(IntentPayload intent) => intent is FinishMulliganIntent or SkipRideIntent or EnterBattleIntent or EndTurnIntent; private static string PhaseLabel(IntentPayload intent) => intent switch { FinishMulliganIntent => "Закончить муллиган", SkipRideIntent => "Пропустить смену", EnterBattleIntent => "В бой", EndTurnIntent => "Закончить ход", _ => intent.GetType().Name, }; private void BuildOverlaysAndZones() { _overlays = new OverlayNavigationService(_root, ResolveCatalogItem); _overlays.OpenChanged += OnOverlayOpenChanged; _selfZones = new ZoneBlockStripView(); _selfZones.Build( _root, "ZoneBlocksSelf", new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(30f, 24f), new Vector2(360f, 108f)); _selfZones.ZoneClicked += zone => OpenZone(zone, _view?.You ?? 0); _opponentZones = new ZoneBlockStripView(); _opponentZones.Build( _root, "ZoneBlocksOpponent", new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-488f, -108f), new Vector2(360f, 108f)); _opponentZones.ZoneClicked += zone => OpenZone(zone, _view != null ? 1 - _view.You : 1); } private void BuildCombatStats() { _combatStats = UiKit.CreatePanel( _root, "CombatStats", new Color(0.03f, 0.045f, 0.07f, 0.9f)); _combatStats.anchorMin = new Vector2(0.5f, 1f); _combatStats.anchorMax = new Vector2(0.5f, 1f); _combatStats.pivot = new Vector2(0.5f, 1f); _combatStats.anchoredPosition = new Vector2(0f, -18f); _combatStats.sizeDelta = new Vector2(620f, 48f); var statsImage = _combatStats.GetComponent(); if (statsImage != null) statsImage.raycastTarget = false; _combatStatsText = UiKit.CreateText( _combatStats, "Text", string.Empty, 20, new Color(0.9f, 0.94f, 1f), TextAnchor.MiddleCenter); UiKit.Stretch(_combatStatsText.rectTransform); _combatStatsText.fontStyle = FontStyle.Bold; UiKit.MakeLiteral(_combatStatsText); _combatStats.gameObject.SetActive(false); } private void UpdateCombatStats() { if (_combatStats == null || _combatStatsText == null) return; var battle = _view?.Battle; if (battle == null) { _combatStats.gameObject.SetActive(false); return; } var defenderIndex = 1 - battle.AttackerPlayer; var attacker = BattleCard(battle.AttackerPlayer, battle.AttackerSlot); var defender = battle.InterceptorSlot >= 0 ? BattleCard(defenderIndex, battle.InterceptorSlot) : BattleCard(defenderIndex, battle.TargetSlot); defender ??= BattleCard(defenderIndex, battle.TargetSlot); if (attacker == null || defender == null) { _combatStats.gameObject.SetActive(false); return; } var attackPower = attacker.Power; var shield = defender.Power + battle.GuardShield; var text = battle.Nullified ? "POWER " + attackPower + " · NULLIFIED" : "POWER " + attackPower + " vs SHIELD " + shield; _combatStatsText.text = text; _combatStatsText.color = battle.Nullified ? new Color(0.7f, 0.76f, 0.9f) : attackPower >= shield ? new Color(1f, 0.72f, 0.32f) : new Color(0.52f, 0.82f, 1f); _combatStats.gameObject.SetActive(true); } private CardView BattleCard(int playerIndex, int slot) { var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex); if (player == null) return null; if (slot < 0) return player.Vanguard; return player.RearGuards != null && slot < player.RearGuards.Length ? player.RearGuards[slot] : null; } private void UpdateZoneStrips() { if (_view == null) return; var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); var opponent = _view.Players.FirstOrDefault(player => player.Index != _view.You); _selfZones?.Update(self, self: true); _opponentZones?.Update(opponent, self: false); } private void OpenZone(BoardZone zone, int playerIndex) { if (_view == null) return; var player = _view.Players.FirstOrDefault(item => item.Index == playerIndex); if (player == null) return; _overlays?.OpenZone(player, _view.You, zone); } private CardCatalogItemDto ResolveCatalogItem(string printingId) => _catalog.TryGetValue(printingId, out var item) ? item : null; private void OnDamageClicked(int displayIndex) { if (_inputLock.IsLocked) return; 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 || _inputLock.IsLocked) return; var indexes = _mulliganSelected.OrderBy(index => index).ToList(); _mulliganSelected.Clear(); _mulliganUsed = true; _autoFinishMulligan = true; _autoFinishMulliganAt = 0f; OnIntentClicked(new MulliganIntent { HandIndexes = indexes }); } private void OnBoardTargetClicked(BoardTarget target) { if (_view == null || _sending || _inputLock.IsLocked) 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(); var guards = sources.OfType().ToList(); if (guards.Count > 0 && guards.Count == sources.Count) { ShowGuardDiscardChoice(guards); return; } 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 && sources.Count > 1) { var abilities = sources.OfType().ToList(); var other = sources .Where(intent => intent is not ActivateAbilityIntent) .ToList(); var items = other .Select(intent => new CardActionMenuItem( Label(intent), () => OnIntentClicked(intent))) .ToList(); if (abilities.Count > 0) { items.Add(new CardActionMenuItem( "Активировать способность", () => OpenAbilitySelector(abilities))); } ShowChoiceMenu("Действия", items); return; } 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 abilities = playable.OfType().ToList(); var guards = playable.OfType().ToList(); var rides = playable.OfType().ToList(); var board = playable .Where(intent => IsBoardIntent(intent) && intent is not ActivateAbilityIntent && intent is not GuardIntent && intent is not RideIntent) .ToList(); var global = playable .Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent) && intent is not ActivateAbilityIntent) .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); } else { var finishMulligan = (view.LegalIntents ?? new List()) .OfType() .FirstOrDefault(); if (finishMulligan != null) { var finishButton = UiKit.CreateButton( _intents, "MulliganFinish", "Закончить муллиган", () => OnIntentClicked(finishMulligan), 16); UiKit.SetPreferredHeight(finishButton, 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); } if (abilities.Count > 0) AddAbilityButton(abilities); if (guards.Count > 0) AddGuardButton(guards); if (rides.Count > 0) AddRideButtons(rides); 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); button.interactable = !_inputLock.IsLocked; 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 "Не защищаться"; 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; } } } }