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 const float IntentPanelShift = 456f;
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 CancellationTokenSource _reconnectCts;
private bool _reconnecting;
private int _reconnectAttempt;
private RectTransform _reconnectOverlay;
private Text _reconnectOverlayText;
private Text _reconnectOverlayHint;
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 readonly List _discardAbilityChoices =
new List();
private readonly List _discardGuardChoices = new List();
private bool _discardChoiceActive;
private string _discardPrompt;
private Action _discardCancelAction;
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();
BuildReconnectOverlay();
_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;
CancelReconnect();
_view = null;
_finished = false;
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
_concede.interactable = true;
_reconnect.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_pendingIntents.Clear();
_discardChoiceActive = false;
_discardAbilityChoices.Clear();
_discardGuardChoices.Clear();
_discardCancelAction = null;
_discardPrompt = null;
_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);
}
ApplyIntentPanelLayout();
_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()
{
CancelReconnect();
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
if (_connection != null)
{
var connection = _connection;
_connection = null;
DetachConnection(connection);
_ = 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(CancellationToken cancellationToken = default)
{
if (_connecting || _info == null)
return false;
_connecting = true;
_reconnect.gameObject.SetActive(false);
_cts?.Cancel();
_cts?.Dispose();
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
if (_connection != null)
{
var previous = _connection;
_connection = null;
DetachConnection(previous);
await previous.DisposeAsync();
}
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);
return true;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus("Не удалось подключиться: " + exception.Message, new Color(1f, 0.55f, 0.45f));
_reconnect.gameObject.SetActive(false);
if (!_reconnecting)
BeginReconnect("Не удалось подключиться: " + exception.Message);
return false;
}
finally
{
_connecting = false;
}
}
private void DetachConnection(GameConnection connection)
{
if (connection == null)
return;
connection.HelloReceived -= OnHello;
connection.SnapshotReceived -= OnSnapshot;
connection.EventsReceived -= OnEvents;
connection.ClockReceived -= OnClock;
connection.ErrorReceived -= OnError;
connection.Closed -= OnClosed;
}
private void OnHello(ServerHello hello)
{
if (_info != null)
_info.Seat = hello.PlayerIndex;
if (_reconnecting)
FinishReconnect();
}
private void OnSnapshot(ServerSnapshot snapshot)
{
if (!_root.gameObject.activeSelf || snapshot?.View == null)
return;
if (_reconnecting)
FinishReconnect();
_view = snapshot.View;
_finished = _view.IsFinished;
_pendingIntents.Clear();
FinishDiscardSelection();
_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)
{
if (envelope.Event is GameFinishedView)
_finished = true;
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 || _view?.IsFinished == true)
return;
BeginReconnect(reason);
}
private void BeginReconnect(string reason)
{
if (_info == null
|| !_root.gameObject.activeSelf
|| _finished
|| _view?.IsFinished == true)
return;
if (_reconnecting)
{
SetReconnectHint("Соединение потеряно. Переподключаемся…");
SetReconnectReason(reason);
return;
}
_reconnecting = true;
_reconnectAttempt = 0;
_reconnectOverlay?.SetAsLastSibling();
_reconnectOverlay?.gameObject.SetActive(true);
SetReconnectHint("Переподключаемся…");
SetReconnectReason(reason);
_reconnect.gameObject.SetActive(false);
_inputLock.Acquire("reconnect");
_reconnectCts?.Cancel();
_reconnectCts?.Dispose();
_reconnectCts = new CancellationTokenSource();
_ = ReconnectLoopAsync(_reconnectCts.Token);
}
private async Task ReconnectLoopAsync(CancellationToken token)
{
var delays = new[] { 0f, 2f, 10f, 30f };
var attempt = 0;
while (!token.IsCancellationRequested
&& _root.gameObject.activeSelf
&& !_finished)
{
var delay = attempt < delays.Length ? delays[attempt] : 30f;
if (delay > 0f)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(delay), token);
}
catch (OperationCanceledException)
{
return;
}
}
if (token.IsCancellationRequested)
return;
attempt++;
_reconnectAttempt = attempt;
SetReconnectHint("Переподключение, попытка " + attempt + "…");
try
{
var ticket = await _meta.CreateTicketAsync(_info.MatchId);
_info.Ticket = ticket.Ticket;
_info.WsUrl = ticket.WsUrl;
if (await ConnectAsync(token))
{
SetReconnectHint("Соединение восстановлено. Загружаем поле…");
return;
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception exception)
{
Debug.LogException(exception);
SetReconnectHint("Попытка " + attempt + " не удалась. Повторяем…");
}
}
}
private void FinishReconnect()
{
if (!_reconnecting)
return;
_reconnecting = false;
_reconnectAttempt = 0;
if (_reconnectOverlay != null)
_reconnectOverlay.gameObject.SetActive(false);
_inputLock.Release("reconnect");
SetStatus("Соединение восстановлено.", Muted);
}
private void SetReconnectHint(string text)
{
if (_reconnectOverlayHint != null)
_reconnectOverlayHint.text = text ?? string.Empty;
}
private void SetReconnectReason(string reason)
{
if (_reconnectOverlayText == null)
return;
var value = reason;
const string prefix = "Соединение потеряно: ";
while (!string.IsNullOrEmpty(value)
&& value.StartsWith(prefix, StringComparison.Ordinal))
{
value = value.Substring(prefix.Length);
}
_reconnectOverlayText.text = string.IsNullOrWhiteSpace(value)
? "Соединение с сервером прервано. Переподключаемся автоматически."
: value;
}
private void CancelReconnect()
{
_reconnecting = false;
_reconnectAttempt = 0;
_reconnectCts?.Cancel();
_reconnectCts?.Dispose();
_reconnectCts = null;
if (_reconnectOverlay != null)
_reconnectOverlay.gameObject.SetActive(false);
_inputLock.Release("reconnect");
}
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 (_discardChoiceActive)
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);
}
ApplyIntentPanelLayout();
}
private void BuildReconnectOverlay()
{
_reconnectOverlay = UiKit.CreatePanel(
_root,
"ReconnectOverlay",
new Color(0f, 0f, 0f, 0.86f));
var card = UiKit.CreateCenteredCard(
_reconnectOverlay,
"ReconnectCard",
new Vector2(680f, 280f),
Panel);
UiKit.AddVerticalLayout(card, 22, 10);
var title = UiKit.CreateText(
card,
"ReconnectTitle",
"Соединение потеряно",
26,
new Color(1f, 0.78f, 0.42f),
TextAnchor.MiddleCenter);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 42f);
_reconnectOverlayHint = UiKit.CreateText(
card,
"ReconnectHint",
"Переподключаемся…",
18,
Color.white,
TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(_reconnectOverlayHint, 32f);
_reconnectOverlayText = UiKit.CreateText(
card,
"ReconnectReason",
string.Empty,
14,
new Color(0.72f, 0.80f, 0.94f),
TextAnchor.MiddleCenter);
_reconnectOverlayText.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(_reconnectOverlayText, 64f);
var exit = UiKit.CreateButton(
card,
"ReconnectExit",
"Выйти в меню",
OnExitClicked,
17);
UiKit.SetPreferredHeight(exit, 44f);
_reconnectOverlay.gameObject.SetActive(false);
}
private void ApplyIntentPanelLayout()
{
var shift = _intentPanelVisible ? 0f : IntentPanelShift;
if (_phasePanel != null)
_phasePanel.anchoredPosition = new Vector2(-468f + shift, 0f);
if (_opponentZones?.Root != null)
_opponentZones.Root.anchoredPosition = new Vector2(-488f + shift, -108f);
if (_board3D != null)
{
_board3D.SetHandViewportX(
_intentPanelVisible
? (_board3D.Config != null
? _board3D.Config.handViewportX
: 0.44f)
: 0.5f);
}
}
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 void OnReconnectClicked()
{
if (_info == null)
return;
BeginReconnect("Переподключение…");
}
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;
if (_discardChoiceActive)
{
HandleDiscardCardClicked(card);
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 || _discardChoiceActive)
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;
}
if (free != null)
{
ShowChoiceMenu(
"Способность",
new List
{
new CardActionMenuItem(
"Без сброса",
() => OnIntentClicked(free)),
new CardActionMenuItem(
"Сбросить карту в руке",
() => StartDiscardSelection(
"Выберите карту в руке для сброса.",
discardVariants,
null)),
});
return;
}
StartDiscardSelection(
"Выберите карту в руке для сброса.",
discardVariants,
null);
}
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;
}
StartDiscardSelection(
"Perfect Guard: выберите карту в руке для сброса.",
null,
discardVariants,
onClosed);
}
private void HandleDiscardCardClicked(BoardCardRef card)
{
if (_view == null || card.Zone != BoardZone.Hand || card.Player != _view.You)
{
SetStatus("Выберите карту в руке для сброса.", new Color(0.98f, 0.82f, 0.42f));
return;
}
var printing = card.PrintingId;
var ability = _discardAbilityChoices.FirstOrDefault(
item => item.DiscardPrintingId == printing);
if (ability != null)
{
FinishDiscardSelection();
OnIntentClicked(ability);
return;
}
var guard = _discardGuardChoices.FirstOrDefault(
item => item.DiscardPrintingId == printing);
if (guard != null)
{
FinishDiscardSelection();
OnIntentClicked(guard);
return;
}
SetStatus("Эта карта не подходит для сброса.", new Color(1f, 0.55f, 0.45f));
}
private void StartDiscardSelection(
string prompt,
IReadOnlyList abilities,
IReadOnlyList guards,
Action onCancel = null)
{
_discardChoiceActive = true;
_discardPrompt = prompt;
_discardCancelAction = onCancel;
_discardAbilityChoices.Clear();
if (abilities != null)
_discardAbilityChoices.AddRange(abilities);
_discardGuardChoices.Clear();
if (guards != null)
_discardGuardChoices.AddRange(guards);
SetStatus(prompt, new Color(0.98f, 0.82f, 0.42f));
RebuildIntents(_view);
}
private void FinishDiscardSelection()
{
_discardChoiceActive = false;
_discardAbilityChoices.Clear();
_discardGuardChoices.Clear();
_discardCancelAction = null;
_discardPrompt = null;
}
private void CancelDiscardSelection()
{
if (!_discardChoiceActive)
return;
var cancel = _discardCancelAction;
FinishDiscardSelection();
if (_view != null)
{
RebuildIntents(_view);
SetStatus("Выбор сброса отменён.", Muted);
}
cancel?.Invoke();
}
private void BuildDiscardPrompt()
{
var prompt = UiKit.CreateText(
_intents,
"DiscardPrompt",
string.IsNullOrEmpty(_discardPrompt)
? "Выберите карту в руке для сброса."
: _discardPrompt,
16,
new Color(0.98f, 0.82f, 0.42f),
TextAnchor.UpperLeft);
prompt.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(prompt, 72f);
var cancel = UiKit.CreateButton(
_intents,
"DiscardCancel",
"Отмена",
CancelDiscardSelection,
16);
UiKit.SetPreferredHeight(cancel, 44f);
}
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 ride in (view.LegalIntents ?? new List())
.OfType()
.GroupBy(item => item.PrintingId)
.Select(group => group.First()))
{
var captured = ride;
var rideButton = UiKit.CreateButton(
_phasePanel,
"Phase_Ride_" + captured.PrintingId,
"Сменить: " + CardName(captured.PrintingId),
() => OnIntentClicked(captured),
14);
rideButton.interactable = !_inputLock.IsLocked;
UiKit.SetPreferredHeight(rideButton, 40f);
}
foreach (var intent in intents)
{
var captured = intent;
if (captured is FinishMulliganIntent && _mulliganSelected.Count > 0)
{
var exchange = UiKit.CreateButton(
_phasePanel,
"Phase_MulliganExchange",
"Заменить " + _mulliganSelected.Count + " карт",
SendMulligan,
14);
exchange.interactable = !_inputLock.IsLocked;
UiKit.SetPreferredHeight(exchange, 40f);
continue;
}
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);
if (_discardChoiceActive)
{
BuildDiscardPrompt();
return;
}
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 (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;
}
}
}
}