4720 lines
172 KiB
C#
4720 lines
172 KiB
C#
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
|
||
{
|
||
/// <summary>
|
||
/// Серый боевой экран: доска, рука, часы и кнопки всех легальных интентов.
|
||
/// Красоту и анимации добавим поверх этой рабочей логики.
|
||
/// </summary>
|
||
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 TriggerCheckSequence _triggerSequence;
|
||
private string _lastInputLockSummary;
|
||
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
|
||
new Dictionary<string, CardCatalogItemDto>(StringComparer.Ordinal);
|
||
private bool _catalogLoading;
|
||
|
||
private readonly RectTransform _root;
|
||
private RectTransform _intents;
|
||
private Text _topInfo;
|
||
private Text _clock;
|
||
private Text _bank;
|
||
private Text _status;
|
||
private float _statusHideAt;
|
||
private Button _concede;
|
||
private Text _concedeLabel;
|
||
private Button _reconnect;
|
||
private readonly RectTransform _result;
|
||
private RectTransform _logContent;
|
||
private Text _resultText;
|
||
private readonly List<string> _logLines = new List<string>();
|
||
|
||
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 RectTransform _abilityOverlay;
|
||
private Text _abilityOverlayName;
|
||
private RectTransform _abilityOverlayButtons;
|
||
private int _abilityPromptInstanceId;
|
||
private RectTransform _cardChoiceOverlay;
|
||
private Text _cardChoiceTitle;
|
||
private RectTransform _cardChoiceContent;
|
||
private string _cardChoiceKey;
|
||
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<IntentPayload> _pendingIntents = new List<IntentPayload>();
|
||
private readonly List<ActivateAbilityIntent> _discardAbilityChoices =
|
||
new List<ActivateAbilityIntent>();
|
||
private readonly List<GuardIntent> _discardGuardChoices = new List<GuardIntent>();
|
||
private bool _discardChoiceActive;
|
||
private string _discardPrompt;
|
||
private Action _discardCancelAction;
|
||
private int _pendingBoosterSlot = -1;
|
||
private int _pendingBoostAttackerSlot = -2;
|
||
private readonly Dictionary<int, GuardIntent> _guardSelected =
|
||
new Dictionary<int, GuardIntent>();
|
||
private int _guardSelectedInterceptSlot = -1;
|
||
private int _guardDiscardInstanceId;
|
||
private string _guardDiscardPrintingId;
|
||
private ActivateAbilityIntent _pendingAbilityCost;
|
||
private int _abilityCostDiscardInstanceId;
|
||
private int? _triggerPowerSlot;
|
||
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
|
||
private bool _mulliganUsed;
|
||
private int _abilityDamageIndex = -1;
|
||
private readonly HashSet<int> _abilityDamageIndexes = new HashSet<int>();
|
||
|
||
private readonly HashSet<BoardSlotKey> _highlightSlots = new HashSet<BoardSlotKey>();
|
||
private GameBoard3D _board3D;
|
||
private OverlayNavigationService _overlays;
|
||
private PlayerStatsPanelView _selfZones;
|
||
private PlayerStatsPanelView _opponentZones;
|
||
private CardActionMenuView _cardMenu;
|
||
private RectTransform _intentPanel;
|
||
private Text _intentPanelToggleLabel;
|
||
private bool _intentPanelVisible;
|
||
private RectTransform _confirmationBar;
|
||
private RectTransform _phasePanel;
|
||
private RectTransform _combatStats;
|
||
private Text _combatStatsText;
|
||
private RectTransform _actionPrompt;
|
||
private Text _actionPromptTitle;
|
||
private Text _actionPromptHint;
|
||
private string _actionPromptKey;
|
||
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<Image>();
|
||
if (rootImage != null)
|
||
{
|
||
rootImage.color = Color.clear;
|
||
rootImage.raycastTarget = false;
|
||
}
|
||
|
||
var top = UiKit.CreatePanel(_root, "TopBar", new Color(0.02f, 0.035f, 0.055f, 0.92f));
|
||
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, 76f);
|
||
UiKit.AddHorizontalLayout(top, 16, 12);
|
||
|
||
var matchInfo = UiKit.CreatePanel(top, "MatchInfo", Color.clear);
|
||
UiKit.AddVerticalLayout(matchInfo, 0, 0);
|
||
var infoElement = matchInfo.gameObject.AddComponent<LayoutElement>();
|
||
infoElement.preferredWidth = 460f;
|
||
infoElement.flexibleWidth = 1f;
|
||
infoElement.minWidth = 260f;
|
||
|
||
_topInfo = UiKit.CreateText(
|
||
matchInfo, "TurnPhase", "Turn 1", 19, Color.white, TextAnchor.MiddleLeft);
|
||
_topInfo.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(_topInfo, 26f);
|
||
UiKit.MakeLiteral(_topInfo);
|
||
|
||
var clockPanel = UiKit.CreatePanel(top, "ClockPanel", Color.clear);
|
||
UiKit.AddVerticalLayout(clockPanel, 0, 0);
|
||
var clockElement = clockPanel.gameObject.AddComponent<LayoutElement>();
|
||
clockElement.preferredWidth = 140f;
|
||
clockElement.minWidth = 120f;
|
||
|
||
_clock = UiKit.CreateText(
|
||
clockPanel, "Clock", "—", 21, Color.white, TextAnchor.MiddleCenter);
|
||
UiKit.SetPreferredHeight(_clock, 28f);
|
||
|
||
_bank = UiKit.CreateText(
|
||
clockPanel, "Bank", "Банк —", 12, Muted, TextAnchor.MiddleCenter);
|
||
UiKit.SetPreferredHeight(_bank, 16f);
|
||
|
||
var toggle = UiKit.CreateButton(
|
||
top,
|
||
"ToggleIntentPanel",
|
||
"Показать панель",
|
||
ToggleIntentPanel,
|
||
13);
|
||
var toggleElement = UiKit.SetPreferredHeight(toggle, 40f, 0f);
|
||
toggleElement.preferredWidth = 164f;
|
||
_intentPanelToggleLabel = toggle.GetComponentInChildren<Text>();
|
||
UiKit.MakeLiteral(_intentPanelToggleLabel);
|
||
|
||
_concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 15);
|
||
_concedeLabel = _concede.GetComponentInChildren<Text>();
|
||
UiKit.SetPreferredHeight(_concede, 40f, 0f);
|
||
var concedeElement = _concede.GetComponent<LayoutElement>();
|
||
if (concedeElement != null)
|
||
concedeElement.preferredWidth = 108f;
|
||
|
||
_reconnect = UiKit.CreateButton(
|
||
top, "Reconnect", "Переподключиться", OnReconnectClicked, 13);
|
||
UiKit.SetPreferredHeight(_reconnect, 40f, 0f);
|
||
_reconnect.gameObject.SetActive(false);
|
||
|
||
BuildActionPrompt();
|
||
|
||
// Компактная панель подтверждений: без заголовка и фона, растёт
|
||
// снизу вверх по количеству доступных кнопок.
|
||
_confirmationBar = UiKit.CreatePanel(
|
||
_root,
|
||
"ConfirmationBar",
|
||
Color.clear);
|
||
_confirmationBar.anchorMin = new Vector2(1f, 0f);
|
||
_confirmationBar.anchorMax = new Vector2(1f, 0f);
|
||
_confirmationBar.pivot = new Vector2(1f, 0f);
|
||
_confirmationBar.anchoredPosition = new Vector2(-28f, 28f);
|
||
_confirmationBar.sizeDelta = new Vector2(300f, 0f);
|
||
var barImage = _confirmationBar.GetComponent<Image>();
|
||
if (barImage != null)
|
||
barImage.raycastTarget = false;
|
||
UiKit.AddVerticalLayout(_confirmationBar, 0, 6);
|
||
var barFitter = _confirmationBar.gameObject.AddComponent<ContentSizeFitter>();
|
||
barFitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
|
||
barFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||
_intents = _confirmationBar;
|
||
|
||
_status = UiKit.CreateText(
|
||
_root, "StatusToast", string.Empty, 15, Color.white, TextAnchor.MiddleLeft);
|
||
var statusRect = _status.rectTransform;
|
||
statusRect.anchorMin = new Vector2(0f, 1f);
|
||
statusRect.anchorMax = new Vector2(0f, 1f);
|
||
statusRect.pivot = new Vector2(0f, 1f);
|
||
statusRect.anchoredPosition = new Vector2(24f, -86f);
|
||
statusRect.sizeDelta = new Vector2(720f, 44f);
|
||
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
_status.raycastTarget = false;
|
||
_status.gameObject.SetActive(false);
|
||
|
||
_intentPanel = UiKit.CreatePanel(
|
||
_root, "DebugPanel", new Color(0.02f, 0.035f, 0.055f, 0.94f));
|
||
_intentPanel.anchorMin = new Vector2(1f, 0f);
|
||
_intentPanel.anchorMax = new Vector2(1f, 1f);
|
||
_intentPanel.pivot = new Vector2(1f, 0.5f);
|
||
_intentPanel.offsetMin = new Vector2(-456f, 28f);
|
||
_intentPanel.offsetMax = new Vector2(-28f, -96f);
|
||
UiKit.AddVerticalLayout(_intentPanel, 14, 8);
|
||
|
||
var actionsTitle = UiKit.CreateText(
|
||
_intentPanel, "ActionsTitle", "DEBUG", 20, Color.white, TextAnchor.MiddleLeft);
|
||
actionsTitle.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(actionsTitle, 28f);
|
||
|
||
var logTitle = UiKit.CreateText(
|
||
_intentPanel, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
|
||
logTitle.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(logTitle, 22f);
|
||
|
||
var logScroll = UiKit.CreateScrollView(_intentPanel, "LogScroll", out _logContent, 3f);
|
||
var logElement = logScroll.gameObject.AddComponent<LayoutElement>();
|
||
logElement.flexibleHeight = 1f;
|
||
logElement.minHeight = 160f;
|
||
|
||
_intentPanelVisible = false;
|
||
_intentPanel.gameObject.SetActive(false);
|
||
|
||
_result = BuildResultPanel(_root);
|
||
_result.gameObject.SetActive(false);
|
||
|
||
BuildOverlaysAndZones();
|
||
BuildCombatStats();
|
||
BuildAbilityOverlay();
|
||
BuildCardChoiceOverlay();
|
||
BuildReconnectOverlay();
|
||
_cardMenu = new CardActionMenuView();
|
||
_cardMenu.Build(_root);
|
||
|
||
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
|
||
_board3D.ConfigureRuntime(_animationConfig, _inputLock);
|
||
_triggerSequence = new TriggerCheckSequence(
|
||
_board3D.CameraDirector,
|
||
_inputLock);
|
||
_triggerSequence.FocusChanged += OnTriggerFocusChanged;
|
||
_triggerSequence.ZoomCompleted += OnTriggerZoomCompleted;
|
||
_triggerSequence.ResolveReady += OnTriggerResolveReady;
|
||
_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;
|
||
_triggerWasPending = false;
|
||
_lastTriggerKey = null;
|
||
_triggerZonePlayer = -1;
|
||
_triggerZoneNoTrigger = false;
|
||
_triggerZoomStartedByEvent = false;
|
||
_suppressNextDamageZoom = false;
|
||
_vanguardRevealActive = false;
|
||
_introActive = false;
|
||
_introStage = 0;
|
||
_introDeadline = 0f;
|
||
_triggerSequence?.Cancel();
|
||
_confirmConcede = false;
|
||
_concedeLabel.text = "Сдаться";
|
||
_concede.interactable = true;
|
||
_reconnect.gameObject.SetActive(false);
|
||
_result.gameObject.SetActive(false);
|
||
_pendingIntents.Clear();
|
||
HideActionPrompt();
|
||
_discardChoiceActive = false;
|
||
_discardAbilityChoices.Clear();
|
||
_discardGuardChoices.Clear();
|
||
_discardCancelAction = null;
|
||
_discardPrompt = null;
|
||
_highlightSlots.Clear();
|
||
_mulliganSelected.Clear();
|
||
_mulliganUsed = false;
|
||
_abilityDamageIndex = -1;
|
||
_abilityDamageIndexes.Clear();
|
||
_pendingBoosterSlot = -1;
|
||
_pendingBoostAttackerSlot = -2;
|
||
ClearGuardSelection();
|
||
ClearAbilityCostSelection();
|
||
_triggerPowerSlot = null;
|
||
_showAllIntents = false;
|
||
_autoFinishMulligan = false;
|
||
_autoFinishMulliganAt = 0f;
|
||
_autoEndBattleArmed = false;
|
||
_autoEndBattleAt = 0f;
|
||
_intentPanelVisible = false;
|
||
if (_intentPanel != null)
|
||
_intentPanel.gameObject.SetActive(false);
|
||
if (_intentPanelToggleLabel != null)
|
||
{
|
||
_intentPanelToggleLabel.text = "Показать панель";
|
||
UiKit.MakeLiteral(_intentPanelToggleLabel);
|
||
}
|
||
ApplyIntentPanelLayout();
|
||
_inputLock.ReleaseAll();
|
||
_logLines.Clear();
|
||
RebuildLog();
|
||
_confirmationBar?.gameObject.SetActive(false);
|
||
_combatStats?.gameObject.SetActive(false);
|
||
_board3D?.Clear();
|
||
_board3D?.SetVisible(true);
|
||
StartBattleIntro();
|
||
_overlays?.CloseAll();
|
||
HideAbilityOverlay();
|
||
HideCardChoiceOverlay();
|
||
_selfZones?.Update(null, true);
|
||
_opponentZones?.Update(null, false);
|
||
_topInfo.text = "Turn 1";
|
||
UiKit.MakeLiteral(_topInfo);
|
||
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;
|
||
_triggerWasPending = false;
|
||
_lastTriggerKey = null;
|
||
_triggerZonePlayer = -1;
|
||
_triggerZoneNoTrigger = false;
|
||
_triggerZoomStartedByEvent = false;
|
||
_suppressNextDamageZoom = false;
|
||
_vanguardRevealActive = false;
|
||
_introActive = false;
|
||
_introStage = 0;
|
||
_introDeadline = 0f;
|
||
_triggerSequence?.Cancel();
|
||
HideActionPrompt();
|
||
_root.gameObject.SetActive(false);
|
||
_result.gameObject.SetActive(false);
|
||
_confirmationBar?.gameObject.SetActive(false);
|
||
_overlays?.CloseAll();
|
||
HideAbilityOverlay();
|
||
HideCardChoiceOverlay();
|
||
_cardMenu?.Hide();
|
||
_combatStats?.gameObject.SetActive(false);
|
||
_board3D?.SetVisible(false);
|
||
_inputLock.ReleaseAll();
|
||
}
|
||
|
||
public void Tick()
|
||
{
|
||
if (_confirmConcede && Time.unscaledTime > _confirmDeadline)
|
||
{
|
||
_confirmConcede = false;
|
||
_concedeLabel.text = "Сдаться";
|
||
}
|
||
|
||
if (_vanguardRevealActive
|
||
&& Time.unscaledTime >= _vanguardRevealUntil)
|
||
{
|
||
_vanguardRevealActive = false;
|
||
_inputLock.Release("vanguard-reveal");
|
||
}
|
||
|
||
TickIntro();
|
||
UpdateClock();
|
||
if (_status != null
|
||
&& _status.gameObject.activeSelf
|
||
&& Time.unscaledTime > _statusHideAt)
|
||
{
|
||
_status.gameObject.SetActive(false);
|
||
}
|
||
|
||
_triggerSequence?.Tick(_animationConfig);
|
||
RefreshActionPrompt();
|
||
SanitizeInputLocks();
|
||
SyncAbilityPrompt();
|
||
SyncCardChoicePrompt();
|
||
UpdateAutoPhases();
|
||
}
|
||
|
||
private void SanitizeInputLocks()
|
||
{
|
||
if (!_inputLock.IsLocked)
|
||
{
|
||
_lastInputLockSummary = null;
|
||
return;
|
||
}
|
||
|
||
// Locks must never outlive the UI/gameplay state that owns them.
|
||
// This also recovers from a missed close callback after reconnects,
|
||
// overlays or card menus.
|
||
if (!_reconnecting)
|
||
_inputLock.Release("reconnect");
|
||
if (_overlays == null || !_overlays.IsOpen)
|
||
_inputLock.Release("overlay");
|
||
if (_cardMenu == null || !_cardMenu.IsVisible)
|
||
_inputLock.Release("card-menu");
|
||
if (_abilityOverlay == null || !_abilityOverlay.gameObject.activeSelf)
|
||
_inputLock.Release("ability");
|
||
if (_cardChoiceOverlay == null || !_cardChoiceOverlay.gameObject.activeSelf)
|
||
_inputLock.Release("card-choice");
|
||
if (!_vanguardRevealActive)
|
||
_inputLock.Release("vanguard-reveal");
|
||
if (!_introActive)
|
||
_inputLock.Release("battle-intro");
|
||
|
||
var summary = _inputLock.Describe();
|
||
if (!_inputLock.IsLocked)
|
||
{
|
||
_lastInputLockSummary = null;
|
||
return;
|
||
}
|
||
|
||
if (!string.Equals(summary, _lastInputLockSummary, StringComparison.Ordinal))
|
||
{
|
||
_lastInputLockSummary = summary;
|
||
Debug.LogWarning("Battle input lock held: " + summary);
|
||
}
|
||
}
|
||
|
||
private void UpdateAutoPhases()
|
||
{
|
||
if (_view == null || _sending || _reconnecting)
|
||
return;
|
||
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
|
||
if (_autoFinishMulligan)
|
||
{
|
||
if (_view.Phase != GamePhase.Mulligan)
|
||
{
|
||
_autoFinishMulligan = false;
|
||
_autoFinishMulliganAt = 0f;
|
||
}
|
||
else if (_view.ActivePlayer == _view.You)
|
||
{
|
||
var finish = legal.OfType<FinishMulliganIntent>().FirstOrDefault();
|
||
if (finish != null)
|
||
{
|
||
if (_autoFinishMulliganAt <= 0f)
|
||
{
|
||
_autoFinishMulliganAt = Time.unscaledTime + 0.6f;
|
||
}
|
||
else if (Time.unscaledTime >= _autoFinishMulliganAt
|
||
&& (_board3D == null
|
||
|| _board3D.MoveAnimator == null
|
||
|| !_board3D.MoveAnimator.IsBusy)
|
||
&& (_board3D == null
|
||
|| _board3D.ReturningCardCount == 0)
|
||
&& (_board3D == null || !_board3D.IsHandDrawActive))
|
||
{
|
||
_autoFinishMulligan = false;
|
||
_autoFinishMulliganAt = 0f;
|
||
// After an exchange the mulligan must finish by itself;
|
||
// other locks must not block that transition.
|
||
OnIntentClicked(finish, ignoreInputLock: true);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (_inputLock.IsLocked)
|
||
return;
|
||
|
||
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<EndTurnIntent>().FirstOrDefault();
|
||
|
||
if (battle && !actionable && endTurn != null && _pendingIntents.Count == 0)
|
||
{
|
||
if (!_autoEndBattleArmed)
|
||
{
|
||
_autoEndBattleArmed = true;
|
||
_autoEndBattleAt = Time.unscaledTime + 1.0f;
|
||
}
|
||
else if (Time.unscaledTime >= _autoEndBattleAt)
|
||
{
|
||
_autoEndBattleArmed = false;
|
||
OnIntentClicked(endTurn);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_autoEndBattleArmed = false;
|
||
}
|
||
}
|
||
|
||
private void OnInputLockChanged(bool locked)
|
||
{
|
||
if (_view == null)
|
||
return;
|
||
|
||
RebuildIntents(_view);
|
||
}
|
||
|
||
private void OnOverlayOpenChanged(bool open)
|
||
{
|
||
if (open)
|
||
_inputLock.Acquire("overlay");
|
||
else
|
||
_inputLock.Release("overlay");
|
||
}
|
||
|
||
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(animateMoves: false);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogWarning("Battle catalog load failed: " + exception.Message);
|
||
}
|
||
finally
|
||
{
|
||
_catalogLoading = false;
|
||
}
|
||
}
|
||
|
||
private async Task<bool> 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();
|
||
if (_mulliganUsed
|
||
&& _view.Phase == GamePhase.Mulligan
|
||
&& _view.ActivePlayer == _view.You)
|
||
{
|
||
// Recovery path: if the automatic finish was interrupted by a
|
||
// reconnect, re-arm it from the authoritative snapshot.
|
||
_autoFinishMulligan = true;
|
||
_autoFinishMulliganAt = 0f;
|
||
}
|
||
FinishDiscardSelection();
|
||
PruneGuardSelection();
|
||
PruneAbilityCostSelection();
|
||
_triggerPowerSlot = null;
|
||
_triggerSelectionReady = false;
|
||
if (_triggerSequence == null || !_triggerSequence.IsBusy)
|
||
PrepareTriggerSelection();
|
||
if (_view.Clock != null)
|
||
{
|
||
_clockServerMs = _view.Clock.ServerTimeMs;
|
||
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
}
|
||
|
||
Render();
|
||
SyncTriggerSequence();
|
||
SyncAbilityPrompt();
|
||
SyncCardChoicePrompt();
|
||
}
|
||
|
||
private bool _triggerWasPending;
|
||
private string _lastTriggerKey;
|
||
private int _triggerZonePlayer = -1;
|
||
private bool _triggerZoneNoTrigger;
|
||
private bool _triggerZoomStartedByEvent;
|
||
private bool _triggerSelectionReady;
|
||
private bool _suppressNextDamageZoom;
|
||
private bool _triggerCameraFocused;
|
||
private bool _vanguardRevealActive;
|
||
private float _vanguardRevealUntil;
|
||
private bool _introActive;
|
||
private int _introStage;
|
||
private float _introDeadline;
|
||
|
||
private void OnTriggerFocusChanged(bool focused)
|
||
{
|
||
_triggerCameraFocused = focused;
|
||
_board3D?.SetHandVisible(!focused);
|
||
if (focused)
|
||
return;
|
||
|
||
if (_triggerZonePlayer >= 0)
|
||
{
|
||
_board3D?.SetTriggerZoneMessage(_triggerZonePlayer, string.Empty);
|
||
_triggerZonePlayer = -1;
|
||
}
|
||
|
||
_triggerZoneNoTrigger = false;
|
||
_triggerZoomStartedByEvent = false;
|
||
}
|
||
|
||
private void OnTriggerZoomCompleted()
|
||
{
|
||
if (_triggerZonePlayer < 0 || _board3D == null)
|
||
return;
|
||
|
||
_board3D.SetTriggerZoneVisible(_triggerZonePlayer, true);
|
||
_board3D.SetTriggerZoneMessage(
|
||
_triggerZonePlayer,
|
||
_triggerZoneNoTrigger ? "БЕЗ ТРИГГЕРА" : string.Empty);
|
||
|
||
if (!_triggerZoneNoTrigger)
|
||
{
|
||
_board3D.RevealTriggerCard(
|
||
_triggerZonePlayer,
|
||
_animationConfig != null
|
||
? _animationConfig.triggerRevealDuration
|
||
: 0.35f);
|
||
}
|
||
}
|
||
|
||
private void OnTriggerResolveReady()
|
||
{
|
||
if (_triggerZoneNoTrigger
|
||
|| _triggerZonePlayer < 0
|
||
|| _view?.Trigger == null
|
||
|| _view.Trigger.Player != _triggerZonePlayer
|
||
|| _view.ActivePlayer != _view.You)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PrepareTriggerSelection();
|
||
if (_triggerSelectionReady)
|
||
{
|
||
RebuildIntents(_view);
|
||
RefreshActionPrompt();
|
||
}
|
||
}
|
||
|
||
private void StartBattleIntro()
|
||
{
|
||
if (_board3D == null || _animationConfig == null)
|
||
return;
|
||
|
||
_introActive = true;
|
||
_introStage = 0;
|
||
_introDeadline = Time.unscaledTime + _animationConfig.introOverheadHold;
|
||
_board3D.SetHandVisible(false);
|
||
_board3D.SetOpponentHandVisible(false);
|
||
_board3D.SetCameraIntro();
|
||
_inputLock.Acquire("battle-intro");
|
||
}
|
||
|
||
private void TickIntro()
|
||
{
|
||
if (!_introActive || _board3D == null || _animationConfig == null)
|
||
return;
|
||
|
||
var now = Time.unscaledTime;
|
||
if (_introStage == 0 && now >= _introDeadline)
|
||
{
|
||
_introStage = 1;
|
||
_board3D.MoveCameraToMain(_animationConfig.introCameraMoveDuration);
|
||
_introDeadline = now + _animationConfig.introCameraMoveDuration;
|
||
return;
|
||
}
|
||
|
||
if (_introStage == 1 && now >= _introDeadline)
|
||
{
|
||
_introStage = 2;
|
||
_introDeadline = now + _animationConfig.introMainHold;
|
||
return;
|
||
}
|
||
|
||
if (_introStage == 2 && now >= _introDeadline)
|
||
{
|
||
if (_view == null)
|
||
{
|
||
_introDeadline = now + 0.2f;
|
||
return;
|
||
}
|
||
|
||
_introStage = 3;
|
||
_board3D.AnimateHandDraw(_animationConfig.introDrawDuration);
|
||
_introDeadline = now + 0.1f;
|
||
return;
|
||
}
|
||
|
||
if (_introStage == 3
|
||
&& now >= _introDeadline
|
||
&& !_board3D.IsHandDrawActive
|
||
&& (_board3D.MoveAnimator == null
|
||
|| !_board3D.MoveAnimator.IsBusy))
|
||
{
|
||
_introActive = false;
|
||
_inputLock.Release("battle-intro");
|
||
if (_view != null)
|
||
{
|
||
RebuildIntents(_view);
|
||
RefreshActionPrompt();
|
||
}
|
||
}
|
||
}
|
||
|
||
private void BeginVanguardReveal()
|
||
{
|
||
if (_vanguardRevealActive)
|
||
return;
|
||
|
||
_vanguardRevealActive = true;
|
||
_vanguardRevealUntil = Time.unscaledTime
|
||
+ (_animationConfig != null
|
||
? _animationConfig.vanguardRevealDuration
|
||
: 0.8f)
|
||
+ 0.1f;
|
||
_inputLock.Acquire("vanguard-reveal");
|
||
}
|
||
|
||
private void BeginTriggerZoneZoom(int player, bool hasTrigger, bool fromEvent)
|
||
{
|
||
if (_triggerSequence == null || _board3D == null)
|
||
return;
|
||
|
||
if (_triggerSequence.IsBusy && _triggerZonePlayer == player)
|
||
{
|
||
if (hasTrigger && _triggerZoneNoTrigger)
|
||
{
|
||
_triggerZoneNoTrigger = false;
|
||
_triggerWasPending = true;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
_triggerZonePlayer = player;
|
||
_triggerZoneNoTrigger = !hasTrigger;
|
||
_triggerWasPending = hasTrigger;
|
||
_triggerSelectionReady = false;
|
||
_lastTriggerKey = null;
|
||
_triggerZoomStartedByEvent = fromEvent;
|
||
_board3D.SetTriggerZoneVisible(player, false);
|
||
_board3D.SetTriggerZoneMessage(player, string.Empty);
|
||
_triggerSequence.ShowTrigger(
|
||
_animationConfig,
|
||
_board3D.TriggerCameraPoint(player));
|
||
}
|
||
|
||
private void SyncTriggerSequence()
|
||
{
|
||
if (_triggerSequence == null || _view == null)
|
||
return;
|
||
|
||
var trigger = _view.Trigger;
|
||
if (trigger != null)
|
||
{
|
||
var key = trigger.Player + ":" + trigger.PrintingId
|
||
+ ":" + _view.EventSeq;
|
||
|
||
if (_triggerZoomStartedByEvent)
|
||
{
|
||
_triggerZoomStartedByEvent = false;
|
||
_triggerWasPending = true;
|
||
_lastTriggerKey = key;
|
||
return;
|
||
}
|
||
|
||
if (_triggerWasPending
|
||
&& string.Equals(_lastTriggerKey, key, StringComparison.Ordinal))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_triggerWasPending = true;
|
||
_lastTriggerKey = key;
|
||
BeginTriggerZoneZoom(trigger.Player, hasTrigger: true, fromEvent: false);
|
||
return;
|
||
}
|
||
|
||
if (_triggerZoomStartedByEvent)
|
||
_triggerZoomStartedByEvent = false;
|
||
|
||
if (_triggerWasPending)
|
||
{
|
||
_triggerWasPending = false;
|
||
_lastTriggerKey = null;
|
||
_triggerSequence.EndChain(_animationConfig);
|
||
}
|
||
}
|
||
|
||
private void OnEvents(ServerEvents events)
|
||
{
|
||
if (events?.Events != null)
|
||
{
|
||
var batchHasDriveCheck = events.Events
|
||
.Any(envelope => envelope.Event is DriveCheckedView);
|
||
var damageZoomStarted = false;
|
||
|
||
foreach (var envelope in events.Events)
|
||
{
|
||
if (envelope.Event is GameFinishedView)
|
||
_finished = true;
|
||
|
||
if (envelope.Event is MulliganView mulligan)
|
||
{
|
||
_board3D?.QueueHandReturnToDeck(
|
||
mulligan.Player,
|
||
mulligan.Count);
|
||
}
|
||
else if (envelope.Event is CardReturnedToDeckView returned)
|
||
{
|
||
_board3D?.QueueHandReturnToDeck(
|
||
returned.Player,
|
||
1,
|
||
returned.PrintingId);
|
||
}
|
||
|
||
if (_view?.Phase == GamePhase.Mulligan
|
||
&& envelope.Event is PhaseChangedView phase
|
||
&& phase.Phase != GamePhase.Mulligan)
|
||
{
|
||
BeginVanguardReveal();
|
||
}
|
||
|
||
if (envelope.Event is DriveCheckedView drive)
|
||
{
|
||
BeginTriggerZoneZoom(
|
||
drive.Player,
|
||
drive.Trigger != TriggerKind.None,
|
||
fromEvent: true);
|
||
}
|
||
else if (envelope.Event is TriggerResolvedView trigger
|
||
&& !_triggerWasPending)
|
||
{
|
||
BeginTriggerZoneZoom(trigger.Player, hasTrigger: true, fromEvent: true);
|
||
if (!batchHasDriveCheck)
|
||
_suppressNextDamageZoom = true;
|
||
}
|
||
else if (envelope.Event is DamageTakenView damage
|
||
&& !_suppressNextDamageZoom
|
||
&& !damageZoomStarted)
|
||
{
|
||
BeginTriggerZoneZoom(
|
||
damage.Player,
|
||
hasTrigger: false,
|
||
fromEvent: true);
|
||
damageZoomStarted = true;
|
||
}
|
||
else if (envelope.Event is DamageTakenView)
|
||
{
|
||
_suppressNextDamageZoom = false;
|
||
}
|
||
|
||
var line = Describe(envelope.Event);
|
||
if (!string.IsNullOrWhiteSpace(line))
|
||
_logLines.Add(line);
|
||
}
|
||
|
||
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 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 void OnIntentClicked(IntentPayload intent) =>
|
||
OnIntentClicked(intent, ignoreInputLock: false);
|
||
|
||
private async void OnIntentClicked(IntentPayload intent, bool ignoreInputLock)
|
||
{
|
||
if (_sending || _connection == null || _cts == null || _view == null)
|
||
return;
|
||
|
||
if (!ignoreInputLock && _inputLock.IsLocked && intent is not ConcedeIntent)
|
||
return;
|
||
|
||
if (_discardChoiceActive)
|
||
return;
|
||
|
||
if (_pendingAbilityCost != null
|
||
&& intent is not ActivateAbilityIntent
|
||
&& intent is not ConcedeIntent)
|
||
{
|
||
SetStatus(
|
||
"Сначала подтвердите оплату способности.",
|
||
new Color(1f, 0.70f, 0.35f));
|
||
return;
|
||
}
|
||
|
||
if (_abilityDamageIndex >= 0)
|
||
{
|
||
if (intent is ActivateAbilityIntent activate)
|
||
activate.DamageIndex = _abilityDamageIndex;
|
||
else if (intent is ResolveAbilityIntent resolve)
|
||
resolve.DamageIndex = _abilityDamageIndex;
|
||
}
|
||
|
||
_abilityDamageIndex = -1;
|
||
|
||
_abilityDamageIndexes.Clear();
|
||
_pendingIntents.Clear();
|
||
_highlightSlots.Clear();
|
||
_pendingBoosterSlot = -1;
|
||
_pendingBoostAttackerSlot = -2;
|
||
_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)
|
||
{
|
||
_board3D?.ReturnDroppedCard();
|
||
if (intent is MulliganIntent || intent is FinishMulliganIntent)
|
||
{
|
||
_mulliganUsed = false;
|
||
_autoFinishMulligan = false;
|
||
_autoFinishMulliganAt = 0f;
|
||
}
|
||
|
||
SetStatus(
|
||
"Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"),
|
||
new Color(1f, 0.55f, 0.45f));
|
||
if (_view != null)
|
||
Render(animateMoves: false);
|
||
}
|
||
else
|
||
{
|
||
if (intent is MulliganIntent)
|
||
{
|
||
// The exchange itself completes the mulligan; finish it
|
||
// automatically even if an overlay lock is active.
|
||
_mulliganUsed = true;
|
||
_autoFinishMulligan = true;
|
||
_autoFinishMulliganAt = Time.unscaledTime + 0.3f;
|
||
if (_view != null)
|
||
RebuildIntents(_view);
|
||
}
|
||
|
||
SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted);
|
||
await RequestSnapshotAsync();
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// экран закрыли
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogException(exception);
|
||
_board3D?.ReturnDroppedCard();
|
||
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 BuildAbilityOverlay()
|
||
{
|
||
_abilityOverlay = UiKit.CreatePanel(
|
||
_root,
|
||
"AbilityOverlay",
|
||
new Color(0f, 0f, 0f, 0.72f));
|
||
var card = UiKit.CreateCenteredCard(
|
||
_abilityOverlay,
|
||
"AbilityCard",
|
||
new Vector2(640f, 300f),
|
||
Panel);
|
||
UiKit.AddVerticalLayout(card, 22, 10);
|
||
|
||
var title = UiKit.CreateText(
|
||
card,
|
||
"AbilityTitle",
|
||
"Способность",
|
||
22,
|
||
new Color(1f, 0.78f, 0.42f),
|
||
TextAnchor.MiddleCenter);
|
||
title.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(title, 34f);
|
||
|
||
_abilityOverlayName = UiKit.CreateText(
|
||
card,
|
||
"AbilityName",
|
||
string.Empty,
|
||
26,
|
||
Color.white,
|
||
TextAnchor.MiddleCenter);
|
||
_abilityOverlayName.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(_abilityOverlayName, 46f);
|
||
|
||
var hint = UiKit.CreateText(
|
||
card,
|
||
"AbilityHint",
|
||
"Активировать способность?",
|
||
17,
|
||
new Color(0.78f, 0.84f, 0.96f),
|
||
TextAnchor.MiddleCenter);
|
||
UiKit.SetPreferredHeight(hint, 32f);
|
||
|
||
_abilityOverlayButtons = UiKit.CreatePanel(
|
||
card, "AbilityButtons", Color.clear);
|
||
UiKit.AddHorizontalLayout(_abilityOverlayButtons, 0, 10);
|
||
UiKit.SetPreferredHeight(_abilityOverlayButtons, 48f);
|
||
_abilityOverlay.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void SyncAbilityPrompt()
|
||
{
|
||
if (_abilityOverlay == null || _sending)
|
||
return;
|
||
|
||
var ability = _view?.Ability;
|
||
if (ability == null
|
||
|| _view == null
|
||
|| _view.ActivePlayer != _view.You)
|
||
{
|
||
HideAbilityOverlay();
|
||
return;
|
||
}
|
||
|
||
if (_abilityOverlay.gameObject.activeSelf
|
||
&& _abilityPromptInstanceId == ability.InstanceId)
|
||
return;
|
||
|
||
var intents = (_view.LegalIntents ?? new List<IntentPayload>())
|
||
.OfType<ResolveAbilityIntent>()
|
||
.ToList();
|
||
var accept = intents.FirstOrDefault(intent => intent.Accept);
|
||
var skip = intents.FirstOrDefault(intent => !intent.Accept);
|
||
if (accept == null && skip == null)
|
||
return;
|
||
|
||
_abilityPromptInstanceId = ability.InstanceId;
|
||
_abilityOverlayName.text = CardName(ability.PrintingId);
|
||
UiKit.MakeLiteral(_abilityOverlayName);
|
||
ClearChildren(_abilityOverlayButtons);
|
||
|
||
if (accept != null)
|
||
{
|
||
AddAbilityOverlayButton(
|
||
"Активировать",
|
||
accept,
|
||
new Color(0.20f, 0.42f, 0.78f));
|
||
}
|
||
|
||
if (skip != null)
|
||
{
|
||
AddAbilityOverlayButton(
|
||
"Пропустить",
|
||
skip,
|
||
new Color(0.24f, 0.28f, 0.36f));
|
||
}
|
||
|
||
_abilityOverlay.SetAsLastSibling();
|
||
_abilityOverlay.gameObject.SetActive(true);
|
||
_inputLock.Acquire("ability");
|
||
}
|
||
|
||
private void AddAbilityOverlayButton(
|
||
string label,
|
||
ResolveAbilityIntent intent,
|
||
Color color)
|
||
{
|
||
var captured = intent;
|
||
var button = UiKit.CreateButton(
|
||
_abilityOverlayButtons,
|
||
"Ability_" + label,
|
||
label,
|
||
() =>
|
||
{
|
||
HideAbilityOverlay();
|
||
OnIntentClicked(captured, ignoreInputLock: true);
|
||
},
|
||
18);
|
||
button.image.color = color;
|
||
UiKit.SetPreferredHeight(button, 46f);
|
||
}
|
||
|
||
private void HideAbilityOverlay()
|
||
{
|
||
if (_abilityOverlay == null)
|
||
return;
|
||
|
||
_abilityPromptInstanceId = 0;
|
||
_abilityOverlay.gameObject.SetActive(false);
|
||
_inputLock.Release("ability");
|
||
}
|
||
|
||
private bool IsRearGuardChoice()
|
||
{
|
||
var prompt = _view?.Choice?.Prompt;
|
||
return !string.IsNullOrEmpty(prompt)
|
||
&& (prompt.IndexOf("круг", StringComparison.OrdinalIgnoreCase) >= 0
|
||
|| prompt.IndexOf("rear", StringComparison.OrdinalIgnoreCase) >= 0);
|
||
}
|
||
|
||
private void BuildCardChoiceOverlay()
|
||
{
|
||
_cardChoiceOverlay = UiKit.CreatePanel(
|
||
_root,
|
||
"CardChoiceOverlay",
|
||
new Color(0f, 0f, 0f, 0.78f));
|
||
var card = UiKit.CreateCenteredCard(
|
||
_cardChoiceOverlay,
|
||
"CardChoiceCard",
|
||
new Vector2(1120f, 620f),
|
||
Panel);
|
||
UiKit.AddVerticalLayout(card, 20, 10);
|
||
|
||
var title = UiKit.CreateText(
|
||
card,
|
||
"CardChoiceTitle",
|
||
"ВЫБОР КАРТЫ",
|
||
22,
|
||
new Color(1f, 0.80f, 0.42f),
|
||
TextAnchor.MiddleCenter);
|
||
title.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(title, 34f);
|
||
|
||
_cardChoiceTitle = UiKit.CreateText(
|
||
card,
|
||
"CardChoicePrompt",
|
||
string.Empty,
|
||
17,
|
||
new Color(0.82f, 0.88f, 0.98f),
|
||
TextAnchor.MiddleCenter);
|
||
UiKit.SetPreferredHeight(_cardChoiceTitle, 42f);
|
||
|
||
var scroll = UiKit.CreateGridScrollView(
|
||
card,
|
||
"CardChoiceScroll",
|
||
new Vector2(160f, 230f),
|
||
new Vector2(12f, 12f),
|
||
out _cardChoiceContent);
|
||
var element = scroll.gameObject.AddComponent<LayoutElement>();
|
||
element.minHeight = 400f;
|
||
element.flexibleHeight = 1f;
|
||
|
||
_cardChoiceOverlay.gameObject.SetActive(false);
|
||
}
|
||
|
||
private static bool TryGetChoicePrintingId(
|
||
ChoiceOptionView option,
|
||
out string printingId)
|
||
{
|
||
printingId = null;
|
||
var label = option?.Label ?? string.Empty;
|
||
var open = label.LastIndexOf('(');
|
||
var close = label.LastIndexOf(')');
|
||
if (open < 0 || close <= open + 1)
|
||
return false;
|
||
|
||
printingId = label.Substring(open + 1, close - open - 1).Trim();
|
||
return printingId.Length > 0;
|
||
}
|
||
|
||
private bool IsCardChoice()
|
||
{
|
||
var options = _view?.Choice?.Options;
|
||
return options is { Count: > 0 }
|
||
&& options.All(option => TryGetChoicePrintingId(option, out _));
|
||
}
|
||
|
||
private bool IsHandChoice()
|
||
{
|
||
var choice = _view?.Choice;
|
||
if (choice == null
|
||
|| _view == null
|
||
|| _view.ActivePlayer != _view.You
|
||
|| choice.Options == null
|
||
|| choice.Options.Count == 0)
|
||
return false;
|
||
|
||
var prompt = choice.Prompt ?? string.Empty;
|
||
if (prompt.IndexOf("рук", StringComparison.OrdinalIgnoreCase) < 0
|
||
&& prompt.IndexOf("сброс", StringComparison.OrdinalIgnoreCase) < 0)
|
||
return false;
|
||
|
||
var hand = _view.Players
|
||
.FirstOrDefault(player => player.Index == _view.You)
|
||
?.Hand;
|
||
return hand != null
|
||
&& choice.Options.All(option => option.Slot >= 0 && option.Slot < hand.Count);
|
||
}
|
||
|
||
private void HandleHandChoiceCard(BoardCardRef card)
|
||
{
|
||
if (_view?.Choice == null || card.Zone != BoardZone.Hand || card.Player != _view.You)
|
||
{
|
||
SetStatus("Выберите карту в руке.", new Color(0.98f, 0.82f, 0.42f));
|
||
return;
|
||
}
|
||
|
||
var handIndex = HandIndexOf(card.InstanceId);
|
||
var optionIndex = _view.Choice.Options
|
||
.ToList()
|
||
.FindIndex(option => option.Slot == handIndex);
|
||
if (optionIndex < 0)
|
||
{
|
||
SetStatus("Эта карта недоступна для выбора.", new Color(1f, 0.55f, 0.45f));
|
||
return;
|
||
}
|
||
|
||
OnIntentClicked(
|
||
new ChooseIntent { Index = optionIndex },
|
||
ignoreInputLock: true);
|
||
}
|
||
|
||
private void SyncCardChoicePrompt()
|
||
{
|
||
if (_cardChoiceOverlay == null || _sending)
|
||
return;
|
||
|
||
var choice = _view?.Choice;
|
||
if (choice == null
|
||
|| _view == null
|
||
|| _view.ActivePlayer != _view.You
|
||
|| !IsCardChoice())
|
||
{
|
||
HideCardChoiceOverlay();
|
||
return;
|
||
}
|
||
|
||
var key = (choice.Prompt ?? string.Empty) + "|" +
|
||
string.Join(",", choice.Options.Select(option => option.Label));
|
||
if (_cardChoiceOverlay.gameObject.activeSelf
|
||
&& string.Equals(_cardChoiceKey, key, StringComparison.Ordinal))
|
||
return;
|
||
|
||
_cardChoiceKey = key;
|
||
_cardChoiceTitle.text = choice.Prompt ?? "Выберите карту";
|
||
UiKit.MakeLiteral(_cardChoiceTitle);
|
||
ClearChildren(_cardChoiceContent);
|
||
|
||
for (var i = 0; i < choice.Options.Count; i++)
|
||
{
|
||
var index = i;
|
||
var option = choice.Options[i];
|
||
if (!TryGetChoicePrintingId(option, out var printingId))
|
||
continue;
|
||
|
||
var tile = UiKit.CreatePanel(
|
||
_cardChoiceContent,
|
||
"CardChoice_" + index,
|
||
new Color(0.05f, 0.075f, 0.11f, 0.98f));
|
||
var tileElement = tile.gameObject.AddComponent<LayoutElement>();
|
||
tileElement.preferredWidth = 160f;
|
||
tileElement.preferredHeight = 230f;
|
||
tileElement.flexibleWidth = 0f;
|
||
tileElement.flexibleHeight = 0f;
|
||
|
||
var button = tile.gameObject.AddComponent<Button>();
|
||
button.targetGraphic = tile.GetComponent<Image>();
|
||
button.onClick.AddListener(() =>
|
||
{
|
||
HideCardChoiceOverlay();
|
||
OnIntentClicked(
|
||
new ChooseIntent { Index = index },
|
||
ignoreInputLock: true);
|
||
});
|
||
|
||
var art = new GameObject(
|
||
"Art", typeof(RectTransform), typeof(Image));
|
||
var artRect = (RectTransform)art.transform;
|
||
artRect.SetParent(tile, false);
|
||
artRect.anchorMin = Vector2.zero;
|
||
artRect.anchorMax = Vector2.one;
|
||
artRect.offsetMin = new Vector2(4f, 34f);
|
||
artRect.offsetMax = new Vector2(-4f, -4f);
|
||
var artImage = art.GetComponent<Image>();
|
||
artImage.raycastTarget = false;
|
||
artImage.preserveAspect = true;
|
||
var meta = ResolveCatalogItem(printingId);
|
||
var imageKey = meta?.ImageKey
|
||
?? CardImageService.ImageKeyForPrinting(printingId);
|
||
CardImageService.Apply(artImage, imageKey, null);
|
||
if (artImage.sprite == null)
|
||
artImage.color = CardArt.ClanColor(meta?.Clan);
|
||
|
||
var label = UiKit.CreateText(
|
||
tile,
|
||
"Name",
|
||
CardName(printingId),
|
||
13,
|
||
new Color(0.92f, 0.95f, 1f),
|
||
TextAnchor.MiddleCenter);
|
||
var labelRect = label.rectTransform;
|
||
labelRect.anchorMin = new Vector2(0f, 0f);
|
||
labelRect.anchorMax = new Vector2(1f, 0f);
|
||
labelRect.pivot = new Vector2(0.5f, 0f);
|
||
labelRect.offsetMin = new Vector2(4f, 2f);
|
||
labelRect.offsetMax = new Vector2(-4f, 32f);
|
||
label.raycastTarget = false;
|
||
}
|
||
|
||
_cardChoiceOverlay.SetAsLastSibling();
|
||
_cardChoiceOverlay.gameObject.SetActive(true);
|
||
_inputLock.Acquire("card-choice");
|
||
}
|
||
|
||
private void HideCardChoiceOverlay()
|
||
{
|
||
if (_cardChoiceOverlay == null)
|
||
return;
|
||
|
||
_cardChoiceKey = null;
|
||
_cardChoiceOverlay.gameObject.SetActive(false);
|
||
_inputLock.Release("card-choice");
|
||
}
|
||
|
||
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()
|
||
{
|
||
if (_board3D != null)
|
||
{
|
||
_board3D.SetHandViewportX(
|
||
_board3D.Config != null
|
||
? _board3D.Config.handViewportX
|
||
: 0.44f);
|
||
}
|
||
}
|
||
|
||
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(bool animateMoves = true)
|
||
{
|
||
if (_view == null)
|
||
return;
|
||
|
||
_cardMenu?.Hide();
|
||
_board3D?.SetSelfPlayer(_view.You);
|
||
_board3D?.Render(_view, BuildBoardRenderState(), animateMoves);
|
||
UpdateCombatStats();
|
||
UpdateZoneStrips();
|
||
|
||
RebuildIntents(_view);
|
||
UpdateClock();
|
||
UpdateTurnHud();
|
||
|
||
if (_view.IsFinished)
|
||
ShowResult(_view);
|
||
}
|
||
|
||
private BoardRenderState BuildBoardRenderState()
|
||
{
|
||
var state = new BoardRenderState
|
||
{
|
||
MulliganSelecting = IsMulliganSelecting(),
|
||
DamageChoice = IsDamageChoice(),
|
||
DamageChoicePlayer = _view?.You ?? -1,
|
||
AbilityDamageTargeting = IsAbilityDamageTargeting(),
|
||
AbilityDamagePlayer = _view?.You ?? -1,
|
||
AbilityDamageIndex = IsAbilityDamageTargeting()
|
||
? _abilityDamageIndex
|
||
: -1,
|
||
MatchFinished = _view != null && _view.IsFinished,
|
||
};
|
||
|
||
if (IsAbilityDamageTargeting())
|
||
{
|
||
foreach (var index in _abilityDamageIndexes)
|
||
state.AbilityDamageIndexes.Add(index);
|
||
}
|
||
|
||
foreach (var key in _highlightSlots)
|
||
state.Highlights.Add(key);
|
||
|
||
if (_view != null)
|
||
{
|
||
if (IsRearGuardChoice())
|
||
{
|
||
foreach (var option in _view.Choice.Options)
|
||
{
|
||
if (option.Slot is >= 0 and < 5)
|
||
state.Highlights.Add(new BoardSlotKey(_view.You, option.Slot));
|
||
}
|
||
}
|
||
|
||
foreach (var intent in _view.LegalIntents ?? new List<IntentPayload>())
|
||
{
|
||
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));
|
||
if (attack.BoosterSlot >= 0)
|
||
{
|
||
state.DropTargets.Add(new BoardSlotKey(
|
||
_view.You,
|
||
attack.AttackerSlot));
|
||
}
|
||
break;
|
||
case GuardIntent _:
|
||
case InterceptIntent _:
|
||
state.GuardianDropAllowed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (_view != null)
|
||
{
|
||
var hand = _view.Players
|
||
.FirstOrDefault(player => player.Index == _view.You)
|
||
?.Hand ?? new List<CardView>();
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
|
||
if (IsMulliganSelecting())
|
||
{
|
||
foreach (var index in _mulliganSelected)
|
||
{
|
||
if (index >= 0 && index < hand.Count)
|
||
state.SelectedHandInstanceIds.Add(hand[index].InstanceId);
|
||
}
|
||
}
|
||
|
||
foreach (var ride in legal.OfType<RideIntent>())
|
||
{
|
||
foreach (var card in hand)
|
||
{
|
||
if (card.PrintingId == ride.PrintingId)
|
||
state.AvailableHandInstanceIds.Add(card.InstanceId);
|
||
}
|
||
}
|
||
|
||
if (IsGuardStep())
|
||
{
|
||
foreach (var guard in legal.OfType<GuardIntent>())
|
||
{
|
||
foreach (var card in hand)
|
||
{
|
||
if (card.PrintingId == guard.PrintingId)
|
||
state.AvailableHandInstanceIds.Add(card.InstanceId);
|
||
}
|
||
}
|
||
|
||
foreach (var instanceId in _guardSelected.Keys)
|
||
if (state.AvailableHandInstanceIds.Contains(instanceId))
|
||
state.SelectedHandInstanceIds.Add(instanceId);
|
||
|
||
foreach (var intercept in legal.OfType<InterceptIntent>())
|
||
{
|
||
state.Highlights.Add(new BoardSlotKey(_view.You, intercept.Slot));
|
||
}
|
||
|
||
if (_guardSelectedInterceptSlot >= 0)
|
||
{
|
||
state.SelectedHighlights.Add(
|
||
new BoardSlotKey(_view.You, _guardSelectedInterceptSlot));
|
||
}
|
||
}
|
||
|
||
if (_discardChoiceActive)
|
||
{
|
||
var discardPrintings = new HashSet<string>(
|
||
_discardAbilityChoices
|
||
.Select(item => item.DiscardPrintingId)
|
||
.Concat(_discardGuardChoices
|
||
.Select(item => item.DiscardPrintingId))
|
||
.Where(item => !string.IsNullOrEmpty(item)));
|
||
foreach (var card in hand)
|
||
{
|
||
if (discardPrintings.Contains(card.PrintingId))
|
||
state.AvailableHandInstanceIds.Add(card.InstanceId);
|
||
}
|
||
}
|
||
|
||
if (_pendingAbilityCost != null
|
||
&& _abilityCostDiscardInstanceId != 0
|
||
&& hand.Any(card =>
|
||
card.InstanceId == _abilityCostDiscardInstanceId))
|
||
{
|
||
state.SelectedHandInstanceIds.Add(_abilityCostDiscardInstanceId);
|
||
}
|
||
}
|
||
|
||
if (IsHandChoice())
|
||
{
|
||
var hand = _view.Players
|
||
.FirstOrDefault(player => player.Index == _view.You)
|
||
?.Hand;
|
||
if (hand != null)
|
||
{
|
||
foreach (var option in _view.Choice.Options)
|
||
{
|
||
if (option.Slot >= 0 && option.Slot < hand.Count)
|
||
state.SelectedHandInstanceIds.Add(hand[option.Slot].InstanceId);
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
meta != null ? meta.Trigger : null);
|
||
}
|
||
|
||
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
|
||
|| _pendingAbilityCost == null
|
||
|| !RequiresCounterBlast(_pendingAbilityCost))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
|
||
return self != null && self.FaceUpDamage.Count > 0;
|
||
}
|
||
|
||
private void OnBoard3DCardClicked(BoardCardRef card)
|
||
{
|
||
if (_view == null || _sending)
|
||
return;
|
||
|
||
if (_discardChoiceActive)
|
||
{
|
||
HandleDiscardCardClicked(card);
|
||
return;
|
||
}
|
||
|
||
if (IsHandChoice())
|
||
{
|
||
HandleHandChoiceCard(card);
|
||
return;
|
||
}
|
||
|
||
if (_inputLock.IsLocked)
|
||
return;
|
||
|
||
if (_pendingAbilityCost != null && card.Zone != BoardZone.Damage)
|
||
{
|
||
SetStatus(
|
||
"Сначала подтвердите оплату способности.",
|
||
new Color(1f, 0.70f, 0.35f));
|
||
return;
|
||
}
|
||
|
||
if (IsGuardStep())
|
||
{
|
||
ToggleGuardSelection(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;
|
||
}
|
||
|
||
if (!targetSelection)
|
||
{
|
||
OpenCardMenu(card);
|
||
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.Player, card.Index);
|
||
break;
|
||
|
||
case BoardZone.Soul:
|
||
SetStatus("Душа открывается правой кнопкой мыши.", Muted);
|
||
break;
|
||
|
||
case BoardZone.Guardian:
|
||
case BoardZone.Trigger:
|
||
OpenCardDetails(card);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private bool IsGuardStep() =>
|
||
_view != null
|
||
&& _view.Step == GameStep.Guard
|
||
&& _view.ActivePlayer == _view.You;
|
||
|
||
private void ToggleGuardSelection(BoardCardRef card)
|
||
{
|
||
if (_view == null || card.Player != _view.You)
|
||
return;
|
||
|
||
if (card.Zone == BoardZone.Hand)
|
||
{
|
||
ToggleGuardHand(card);
|
||
return;
|
||
}
|
||
|
||
if (card.Zone is BoardZone.Vanguard or BoardZone.RearGuard)
|
||
{
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
var intercept = legal.OfType<InterceptIntent>()
|
||
.FirstOrDefault(intent => intent.Slot == card.Slot);
|
||
if (intercept == null)
|
||
{
|
||
SetStatus(
|
||
"Эта карта сейчас не может перехватить атаку.",
|
||
new Color(1f, 0.55f, 0.45f));
|
||
return;
|
||
}
|
||
|
||
_guardSelectedInterceptSlot =
|
||
_guardSelectedInterceptSlot == card.Slot ? -1 : card.Slot;
|
||
RefreshGuardSelection();
|
||
}
|
||
}
|
||
|
||
private void ToggleGuardHand(BoardCardRef card)
|
||
{
|
||
if (_guardSelected.Remove(card.InstanceId))
|
||
{
|
||
RefreshGuardSelection();
|
||
return;
|
||
}
|
||
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
var variants = legal.OfType<GuardIntent>()
|
||
.Where(intent => intent.PrintingId == card.PrintingId)
|
||
.ToList();
|
||
if (variants.Count == 0)
|
||
{
|
||
SetStatus(
|
||
"Эту карту нельзя использовать для защиты.",
|
||
new Color(1f, 0.55f, 0.45f));
|
||
return;
|
||
}
|
||
|
||
var distinct = variants
|
||
.GroupBy(intent => intent.DiscardPrintingId ?? string.Empty)
|
||
.Select(group => group.First())
|
||
.ToList();
|
||
var discardVariants = distinct
|
||
.Where(intent => !string.IsNullOrEmpty(intent.DiscardPrintingId))
|
||
.ToList();
|
||
if (discardVariants.Count == 0)
|
||
{
|
||
AddGuardSelection(card.InstanceId, distinct[0]);
|
||
return;
|
||
}
|
||
|
||
if (discardVariants.Count == 1)
|
||
{
|
||
AddGuardSelection(card.InstanceId, discardVariants[0]);
|
||
return;
|
||
}
|
||
|
||
_guardDiscardInstanceId = card.InstanceId;
|
||
_guardDiscardPrintingId = card.PrintingId;
|
||
StartDiscardSelection(
|
||
"Perfect Guard: выберите карту в руке для сброса.",
|
||
null,
|
||
discardVariants,
|
||
() =>
|
||
{
|
||
_guardDiscardInstanceId = 0;
|
||
_guardDiscardPrintingId = null;
|
||
RefreshGuardSelection();
|
||
});
|
||
Render(animateMoves: false);
|
||
}
|
||
|
||
private void AddGuardSelection(int instanceId, GuardIntent intent)
|
||
{
|
||
_guardSelected[instanceId] = intent;
|
||
RefreshGuardSelection();
|
||
}
|
||
|
||
private void RefreshGuardSelection()
|
||
{
|
||
if (_view == null)
|
||
return;
|
||
|
||
var count = _guardSelected.Count
|
||
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
|
||
SetStatus(
|
||
count == 0
|
||
? "Защита: выберите карты в руке или на поле."
|
||
: "Защита: выбрано " + count + " карт.",
|
||
new Color(0.72f, 0.86f, 1f));
|
||
Render(animateMoves: false);
|
||
}
|
||
|
||
private void ClearGuardSelection()
|
||
{
|
||
_guardSelected.Clear();
|
||
_guardSelectedInterceptSlot = -1;
|
||
_guardDiscardInstanceId = 0;
|
||
_guardDiscardPrintingId = null;
|
||
}
|
||
|
||
private void PruneGuardSelection()
|
||
{
|
||
if (!IsGuardStep())
|
||
{
|
||
ClearGuardSelection();
|
||
return;
|
||
}
|
||
|
||
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
|
||
var hand = self?.Hand ?? new List<CardView>();
|
||
var handIds = new HashSet<int>(hand.Select(card => card.InstanceId));
|
||
foreach (var instanceId in _guardSelected.Keys.ToList())
|
||
{
|
||
if (!handIds.Contains(instanceId))
|
||
_guardSelected.Remove(instanceId);
|
||
}
|
||
|
||
if (_guardSelectedInterceptSlot >= 0
|
||
&& !(_view.LegalIntents ?? new List<IntentPayload>())
|
||
.OfType<InterceptIntent>()
|
||
.Any(intent => intent.Slot == _guardSelectedInterceptSlot))
|
||
{
|
||
_guardSelectedInterceptSlot = -1;
|
||
}
|
||
}
|
||
|
||
private int GuardSelectionShield()
|
||
{
|
||
var shield = 0;
|
||
foreach (var guard in _guardSelected.Values)
|
||
{
|
||
var meta = ResolveCatalogItem(guard.PrintingId);
|
||
if (meta != null && !meta.IsSentinel)
|
||
shield += Math.Max(0, meta.Shield);
|
||
}
|
||
|
||
return shield;
|
||
}
|
||
|
||
private async void ConfirmGuardSelection()
|
||
{
|
||
if (_view == null
|
||
|| _connection == null
|
||
|| _cts == null
|
||
|| _sending)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var intents = new List<IntentPayload>();
|
||
if (_guardSelectedInterceptSlot >= 0)
|
||
{
|
||
var intercept = (_view.LegalIntents ?? new List<IntentPayload>())
|
||
.OfType<InterceptIntent>()
|
||
.FirstOrDefault(intent => intent.Slot == _guardSelectedInterceptSlot);
|
||
if (intercept != null)
|
||
intents.Add(intercept);
|
||
}
|
||
|
||
intents.AddRange(_guardSelected.Values);
|
||
|
||
if (intents.Count == 0)
|
||
{
|
||
OnIntentClicked(new PassGuardIntent());
|
||
return;
|
||
}
|
||
|
||
ClearGuardSelection();
|
||
Render(animateMoves: false);
|
||
_sending = true;
|
||
SetStatus("C", Muted);
|
||
try
|
||
{
|
||
var acceptedAll = true;
|
||
foreach (var intent in intents)
|
||
{
|
||
var result = await _connection.SubmitAsync(intent, _cts.Token);
|
||
if (!result.Accepted)
|
||
{
|
||
acceptedAll = false;
|
||
SetStatus(
|
||
"Защита отклонена (" + result.Code + "): "
|
||
+ (result.Reason ?? "без причины"),
|
||
new Color(1f, 0.55f, 0.45f));
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (acceptedAll)
|
||
{
|
||
// "Защищаться" is the final confirmation: add the selected
|
||
// guards and pass the guard step in one action.
|
||
await _connection.SubmitAsync(new PassGuardIntent(), _cts.Token);
|
||
}
|
||
|
||
await RequestSnapshotAsync();
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// экран закрыли
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogException(exception);
|
||
SetStatus(
|
||
"Ошибка отправки защиты: " + exception.Message,
|
||
new Color(1f, 0.55f, 0.45f));
|
||
}
|
||
finally
|
||
{
|
||
_sending = false;
|
||
if (_view != null)
|
||
Render(animateMoves: false);
|
||
}
|
||
}
|
||
|
||
private bool RequiresCounterBlast(ActivateAbilityIntent intent)
|
||
{
|
||
if (intent == null || _view == null)
|
||
return false;
|
||
|
||
var card = InstanceAt(_view.You, intent.InstanceId);
|
||
var meta = ResolveCatalogItem(card?.PrintingId ?? string.Empty);
|
||
if (meta == null)
|
||
return false;
|
||
|
||
var text = ((meta.TextEn ?? string.Empty) + " "
|
||
+ (meta.TextRu ?? string.Empty)).ToLowerInvariant();
|
||
return text.Contains("counter-blast")
|
||
|| text.Contains("counter blast")
|
||
|| text.Contains("встречн");
|
||
}
|
||
|
||
private int CounterBlastAmount(ActivateAbilityIntent intent)
|
||
{
|
||
if (intent == null || _view == null)
|
||
return 1;
|
||
|
||
var card = InstanceAt(_view.You, intent.InstanceId);
|
||
var meta = ResolveCatalogItem(card?.PrintingId ?? string.Empty);
|
||
if (meta == null)
|
||
return 1;
|
||
|
||
var text = ((meta.TextEn ?? string.Empty) + " "
|
||
+ (meta.TextRu ?? string.Empty)).ToLowerInvariant();
|
||
var markers = new[]
|
||
{
|
||
"counter-blast",
|
||
"counter blast",
|
||
"контр-бласт",
|
||
};
|
||
foreach (var marker in markers)
|
||
{
|
||
var start = text.IndexOf(marker, StringComparison.Ordinal);
|
||
if (start < 0)
|
||
continue;
|
||
|
||
var index = start + marker.Length;
|
||
while (index < text.Length
|
||
&& (text[index] == ' '
|
||
|| text[index] == ':'
|
||
|| text[index] == '('
|
||
|| text[index] == '['))
|
||
{
|
||
index++;
|
||
}
|
||
|
||
var value = 0;
|
||
while (index < text.Length
|
||
&& text[index] >= '0'
|
||
&& text[index] <= '9')
|
||
{
|
||
value = (value * 10) + (text[index] - '0');
|
||
index++;
|
||
}
|
||
|
||
if (value > 0)
|
||
return value;
|
||
}
|
||
|
||
return 1;
|
||
}
|
||
|
||
private void ActivateAbility(ActivateAbilityIntent intent)
|
||
{
|
||
if (intent == null || _view == null || _pendingAbilityCost != null)
|
||
return;
|
||
|
||
if (!string.IsNullOrEmpty(intent.DiscardPrintingId))
|
||
{
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
var variants = legal.OfType<ActivateAbilityIntent>()
|
||
.Where(variant => variant.InstanceId == intent.InstanceId
|
||
&& !string.IsNullOrEmpty(variant.DiscardPrintingId))
|
||
.ToList();
|
||
_pendingAbilityCost = null;
|
||
_abilityCostDiscardInstanceId = 0;
|
||
_abilityDamageIndex = -1;
|
||
_abilityDamageIndexes.Clear();
|
||
StartDiscardSelection(
|
||
"Выберите карту в руке для сброса.",
|
||
variants,
|
||
null);
|
||
Render(animateMoves: false);
|
||
return;
|
||
}
|
||
|
||
if (RequiresCounterBlast(intent))
|
||
{
|
||
BeginAbilityCost(intent);
|
||
return;
|
||
}
|
||
|
||
OnIntentClicked(intent);
|
||
}
|
||
|
||
private void BeginAbilityCost(ActivateAbilityIntent intent)
|
||
{
|
||
_pendingAbilityCost = intent;
|
||
_abilityCostDiscardInstanceId = 0;
|
||
_abilityDamageIndex = -1;
|
||
_abilityDamageIndexes.Clear();
|
||
_pendingIntents.Clear();
|
||
_highlightSlots.Clear();
|
||
SetStatus(
|
||
"Оплата способности: выберите карты для цены.",
|
||
new Color(0.98f, 0.82f, 0.42f));
|
||
Render(animateMoves: false);
|
||
}
|
||
|
||
private void ConfirmAbilityCost()
|
||
{
|
||
if (_pendingAbilityCost == null || _view == null || _sending)
|
||
return;
|
||
|
||
if (RequiresCounterBlast(_pendingAbilityCost)
|
||
&& _abilityDamageIndexes.Count
|
||
!= CounterBlastAmount(_pendingAbilityCost))
|
||
{
|
||
SetStatus(
|
||
"Выберите карты урона для Counter Blast: "
|
||
+ _abilityDamageIndexes.Count + " / "
|
||
+ CounterBlastAmount(_pendingAbilityCost) + ".",
|
||
new Color(1f, 0.70f, 0.35f));
|
||
return;
|
||
}
|
||
|
||
var activate = new ActivateAbilityIntent
|
||
{
|
||
InstanceId = _pendingAbilityCost.InstanceId,
|
||
DiscardPrintingId = _pendingAbilityCost.DiscardPrintingId,
|
||
DamageIndex = _abilityDamageIndex,
|
||
DamageIndexes = _abilityDamageIndexes
|
||
.OrderBy(index => index)
|
||
.ToArray(),
|
||
};
|
||
ClearAbilityCostSelection();
|
||
OnIntentClicked(activate);
|
||
}
|
||
|
||
private void ClearAbilityCostSelection()
|
||
{
|
||
_pendingAbilityCost = null;
|
||
_abilityCostDiscardInstanceId = 0;
|
||
_abilityDamageIndex = -1;
|
||
_abilityDamageIndexes.Clear();
|
||
}
|
||
|
||
private void PruneAbilityCostSelection()
|
||
{
|
||
if (_pendingAbilityCost == null)
|
||
return;
|
||
|
||
if (_view == null || _view.ActivePlayer != _view.You)
|
||
{
|
||
ClearAbilityCostSelection();
|
||
return;
|
||
}
|
||
|
||
var stillLegal = (_view.LegalIntents ?? new List<IntentPayload>())
|
||
.OfType<ActivateAbilityIntent>()
|
||
.Any(intent => intent.InstanceId == _pendingAbilityCost.InstanceId);
|
||
if (!stillLegal)
|
||
ClearAbilityCostSelection();
|
||
}
|
||
|
||
private bool IsAbilityCostReady()
|
||
{
|
||
if (_pendingAbilityCost == null)
|
||
return false;
|
||
|
||
if (!RequiresCounterBlast(_pendingAbilityCost))
|
||
return true;
|
||
|
||
return _abilityDamageIndexes.Count
|
||
== CounterBlastAmount(_pendingAbilityCost);
|
||
}
|
||
|
||
private void ToggleGuardFromDrop(BoardCardRef source)
|
||
{
|
||
if (!IsGuardStep())
|
||
return;
|
||
|
||
ToggleGuardSelection(source);
|
||
}
|
||
|
||
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,
|
||
card.Zone == BoardZone.Hand);
|
||
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
var sources = legal
|
||
.Where(intent => TryGetSource(intent, out var source) && source.Matches(target))
|
||
.ToList();
|
||
|
||
var actions = new List<CardActionMenuItem>();
|
||
var attacks = sources.OfType<AttackIntent>().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<MoveIntent>().ToList();
|
||
if (moves.Count > 0)
|
||
{
|
||
if (moves.Count == 1)
|
||
{
|
||
var move = moves[0];
|
||
actions.Add(new CardActionMenuItem(
|
||
"Переместить",
|
||
() => OnIntentClicked(move)));
|
||
}
|
||
else
|
||
{
|
||
actions.Add(new CardActionMenuItem("Переместить", () =>
|
||
{
|
||
BeginTargetSelection(
|
||
moves,
|
||
"Выберите круг для перемещения.");
|
||
}));
|
||
}
|
||
}
|
||
|
||
var calls = sources.OfType<CallIntent>().ToList();
|
||
if (calls.Count > 0)
|
||
{
|
||
actions.Add(new CardActionMenuItem("Призвать", () =>
|
||
{
|
||
BeginTargetSelection(calls, "Выберите круг для призыва.");
|
||
}));
|
||
}
|
||
|
||
var rides = sources.OfType<RideIntent>().ToList();
|
||
if (rides.Count > 0)
|
||
{
|
||
var ride = rides[0];
|
||
actions.Add(new CardActionMenuItem(
|
||
"Сменить: " + CardName(ride.PrintingId),
|
||
() => OnIntentClicked(ride)));
|
||
}
|
||
|
||
var abilities = sources.OfType<ActivateAbilityIntent>().ToList();
|
||
if (abilities.Count == 0)
|
||
{
|
||
// Fallback: never hide an activated ability because source
|
||
// target resolution failed for an unusual card placement.
|
||
abilities = legal.OfType<ActivateAbilityIntent>()
|
||
.Where(ability => ability.InstanceId == card.InstanceId)
|
||
.ToList();
|
||
}
|
||
|
||
if (abilities.Count > 0)
|
||
{
|
||
actions.Add(new CardActionMenuItem(
|
||
"Активировать способность",
|
||
() => OpenAbilitySelector(abilities)));
|
||
}
|
||
|
||
foreach (var intent in sources)
|
||
{
|
||
if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent
|
||
or CallIntent or RideIntent)
|
||
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<IntentPayload> 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 || _discardChoiceActive)
|
||
return;
|
||
|
||
if (IsRearGuardChoice() && slot.Player == _view.You)
|
||
{
|
||
var index = _view.Choice.Options.FindIndex(
|
||
option => option.Slot == slot.Slot);
|
||
if (index >= 0)
|
||
{
|
||
OnIntentClicked(
|
||
new ChooseIntent { Index = index },
|
||
ignoreInputLock: true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (_inputLock.IsLocked)
|
||
return;
|
||
|
||
if (slot.Zone == BoardZone.Guardian)
|
||
return;
|
||
|
||
OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false));
|
||
}
|
||
|
||
private void AddAbilityButton(IReadOnlyList<ActivateAbilityIntent> 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<RideIntent> 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<GuardIntent> 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<GuardIntent> 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<ActivateAbilityIntent> 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<ActivateAbilityIntent> 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)
|
||
{
|
||
ActivateAbility(free ?? distinct[0]);
|
||
return;
|
||
}
|
||
|
||
if (free != null)
|
||
{
|
||
ShowChoiceMenu(
|
||
"Способность",
|
||
new List<CardActionMenuItem>
|
||
{
|
||
new CardActionMenuItem(
|
||
"Без сброса",
|
||
() => ActivateAbility(free)),
|
||
new CardActionMenuItem(
|
||
"Сбросить карту в руке",
|
||
() => StartDiscardSelection(
|
||
"Выберите карту в руке для сброса.",
|
||
discardVariants,
|
||
null)),
|
||
});
|
||
return;
|
||
}
|
||
|
||
StartDiscardSelection(
|
||
"Выберите карту в руке для сброса.",
|
||
discardVariants,
|
||
null);
|
||
}
|
||
|
||
private void ShowGuardDiscardChoice(
|
||
IReadOnlyList<GuardIntent> 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)
|
||
{
|
||
var discardInstanceId = card.InstanceId;
|
||
FinishDiscardSelection();
|
||
_pendingAbilityCost = ability;
|
||
_abilityCostDiscardInstanceId = discardInstanceId;
|
||
_abilityDamageIndex = -1;
|
||
_abilityDamageIndexes.Clear();
|
||
Render(animateMoves: false);
|
||
return;
|
||
}
|
||
|
||
var guard = _discardGuardChoices.FirstOrDefault(
|
||
item => item.DiscardPrintingId == printing);
|
||
if (guard != null)
|
||
{
|
||
var guardInstanceId = _guardDiscardInstanceId;
|
||
FinishDiscardSelection();
|
||
if (guardInstanceId != 0)
|
||
{
|
||
_guardDiscardInstanceId = 0;
|
||
_guardDiscardPrintingId = null;
|
||
AddGuardSelection(guardInstanceId, guard);
|
||
return;
|
||
}
|
||
|
||
OnIntentClicked(guard);
|
||
return;
|
||
}
|
||
|
||
SetStatus("Эта карта не подходит для сброса.", new Color(1f, 0.55f, 0.45f));
|
||
}
|
||
|
||
private void StartDiscardSelection(
|
||
string prompt,
|
||
IReadOnlyList<ActivateAbilityIntent> abilities,
|
||
IReadOnlyList<GuardIntent> 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;
|
||
_guardDiscardInstanceId = 0;
|
||
_guardDiscardPrintingId = 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<CardActionMenuItem> 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<CardView>();
|
||
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)
|
||
{
|
||
_board3D?.ReturnDroppedCard();
|
||
return;
|
||
}
|
||
|
||
if (IsGuardStep()
|
||
&& target.Zone == BoardZone.Guardian
|
||
&& (source.Zone == BoardZone.Hand || source.IsField))
|
||
{
|
||
ToggleGuardFromDrop(source);
|
||
_board3D?.ReturnDroppedCard();
|
||
return;
|
||
}
|
||
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
IntentPayload intent = null;
|
||
Debug.Log($"[Drop] source={source.Zone}:{source.Player}:{source.PrintingId} target={target.Zone}:{target.Player}:{target.Slot} phase={_view.Phase} you={_view.You} active={_view.ActivePlayer} legal={legal.Count} rides=[{string.Join(",", legal.OfType<RideIntent>().Select(item => item.PrintingId))}]");
|
||
|
||
if (_pendingBoosterSlot >= 0 && source.IsField)
|
||
{
|
||
if (source.Slot != _pendingBoostAttackerSlot)
|
||
{
|
||
SetStatus(
|
||
"Поддержка уже объявлена: атаковать можно только поддерживаемой картой.",
|
||
new Color(1f, 0.55f, 0.45f));
|
||
return;
|
||
}
|
||
|
||
intent = legal.OfType<AttackIntent>()
|
||
.FirstOrDefault(item => item.AttackerSlot == source.Slot
|
||
&& item.TargetSlot == target.Slot
|
||
&& item.BoosterSlot == _pendingBoosterSlot);
|
||
}
|
||
else if (source.Zone == BoardZone.Hand)
|
||
{
|
||
if (target.Zone == BoardZone.Guardian)
|
||
{
|
||
var guards = legal.OfType<GuardIntent>()
|
||
.Where(item => item.PrintingId == source.PrintingId)
|
||
.ToList();
|
||
if (guards.Count > 1)
|
||
{
|
||
_board3D?.ReturnDroppedCard();
|
||
ShowGuardDiscardChoice(guards, () => Render(animateMoves: false));
|
||
return;
|
||
}
|
||
|
||
intent = guards.FirstOrDefault();
|
||
}
|
||
else if (target.Player == _view.You && target.Zone == BoardZone.Vanguard)
|
||
{
|
||
intent = legal.OfType<RideIntent>()
|
||
.FirstOrDefault(item => item.PrintingId == source.PrintingId);
|
||
if (intent == null
|
||
&& _view.Phase == GamePhase.Ride
|
||
&& !string.IsNullOrEmpty(source.PrintingId))
|
||
{
|
||
// Сервер всё равно авторитарно проверит карту/фазу.
|
||
// Это спасает от расхождения legal-списка и drag-цели.
|
||
intent = new RideIntent
|
||
{
|
||
PrintingId = source.PrintingId,
|
||
};
|
||
Debug.Log("[Drop] fallback direct RideIntent " + source.PrintingId);
|
||
}
|
||
}
|
||
else if (target.Player == _view.You && target.Zone == BoardZone.RearGuard)
|
||
{
|
||
intent = legal.OfType<CallIntent>()
|
||
.FirstOrDefault(item => item.PrintingId == source.PrintingId
|
||
&& item.Slot == target.Slot);
|
||
}
|
||
}
|
||
else if (source.IsField)
|
||
{
|
||
if (target.Zone == BoardZone.Guardian)
|
||
{
|
||
intent = legal.OfType<InterceptIntent>()
|
||
.FirstOrDefault(item => item.Slot == source.Slot);
|
||
}
|
||
else if (target.Player == _view.You
|
||
&& source.Slot is 0 or 1 or 2
|
||
&& (target.Zone == BoardZone.Vanguard
|
||
|| (target.Zone == BoardZone.RearGuard
|
||
&& target.Slot is 3 or 4)))
|
||
{
|
||
BeginBoostAttack(source.Slot, target.Slot, legal);
|
||
return;
|
||
}
|
||
else if (target.Player == _view.You && target.Zone == BoardZone.RearGuard)
|
||
{
|
||
intent = legal.OfType<MoveIntent>()
|
||
.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<AttackIntent>()
|
||
.FirstOrDefault(item => item.AttackerSlot == source.Slot
|
||
&& item.TargetSlot == target.Slot);
|
||
}
|
||
}
|
||
|
||
if (intent == null)
|
||
{
|
||
Debug.Log("[Drop] no intent");
|
||
_board3D?.ReturnDroppedCard();
|
||
SetStatus("Сюда нельзя положить эту карту.", new Color(1f, 0.55f, 0.45f));
|
||
RefreshBoardHighlights();
|
||
return;
|
||
}
|
||
|
||
ClearSelection(rebuild: false);
|
||
OnIntentClicked(intent);
|
||
}
|
||
|
||
private void BeginBoostAttack(
|
||
int boosterSlot,
|
||
int attackerSlot,
|
||
IReadOnlyList<IntentPayload> legal)
|
||
{
|
||
var attacks = legal.OfType<AttackIntent>()
|
||
.Where(intent => intent.BoosterSlot == boosterSlot
|
||
&& intent.AttackerSlot == attackerSlot)
|
||
.ToList();
|
||
if (attacks.Count == 0)
|
||
{
|
||
SetStatus(
|
||
"Эта карта не может поддержать выбранный круг.",
|
||
new Color(1f, 0.55f, 0.45f));
|
||
return;
|
||
}
|
||
|
||
_pendingBoosterSlot = boosterSlot;
|
||
_pendingBoostAttackerSlot = attackerSlot;
|
||
_pendingIntents.Clear();
|
||
_pendingIntents.AddRange(attacks);
|
||
RebuildHighlights();
|
||
RebuildIntents(_view);
|
||
SetStatus(
|
||
"Поддержка: " + CardName(
|
||
CardAt(_view.You, boosterSlot)?.PrintingId ?? string.Empty)
|
||
+ ". Атакуйте только этой картой и выберите цель.",
|
||
new Color(0.98f, 0.82f, 0.42f));
|
||
}
|
||
|
||
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 (_discardChoiceActive)
|
||
{
|
||
CancelDiscardSelection();
|
||
return;
|
||
}
|
||
|
||
if (_pendingAbilityCost != null)
|
||
{
|
||
ClearAbilityCostSelection();
|
||
SetStatus("Оплата способности отменена.", Muted);
|
||
Render(animateMoves: false);
|
||
return;
|
||
}
|
||
|
||
if (IsGuardStep()
|
||
&& (_guardSelected.Count > 0
|
||
|| _guardSelectedInterceptSlot >= 0))
|
||
{
|
||
ClearGuardSelection();
|
||
SetStatus("Выбор защиты снят.", Muted);
|
||
Render(animateMoves: false);
|
||
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 BuildActionPrompt()
|
||
{
|
||
_actionPrompt = UiKit.CreatePanel(
|
||
_root,
|
||
"ActionPrompt",
|
||
new Color(0.025f, 0.04f, 0.065f, 0.88f));
|
||
_actionPrompt.anchorMin = new Vector2(0.5f, 1f);
|
||
_actionPrompt.anchorMax = new Vector2(0.5f, 1f);
|
||
_actionPrompt.pivot = new Vector2(0.5f, 1f);
|
||
_actionPrompt.anchoredPosition = new Vector2(0f, -142f);
|
||
_actionPrompt.sizeDelta = new Vector2(760f, 72f);
|
||
var image = _actionPrompt.GetComponent<Image>();
|
||
if (image != null)
|
||
image.raycastTarget = false;
|
||
UiKit.AddVerticalLayout(_actionPrompt, 8, 2);
|
||
|
||
_actionPromptTitle = UiKit.CreateText(
|
||
_actionPrompt,
|
||
"ActionPromptTitle",
|
||
string.Empty,
|
||
16,
|
||
new Color(1f, 0.80f, 0.42f),
|
||
TextAnchor.MiddleCenter);
|
||
_actionPromptTitle.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(_actionPromptTitle, 24f);
|
||
|
||
_actionPromptHint = UiKit.CreateText(
|
||
_actionPrompt,
|
||
"ActionPromptHint",
|
||
string.Empty,
|
||
14,
|
||
new Color(0.78f, 0.86f, 0.98f),
|
||
TextAnchor.MiddleCenter);
|
||
UiKit.SetPreferredHeight(_actionPromptHint, 24f);
|
||
_actionPrompt.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void RefreshActionPrompt()
|
||
{
|
||
if (_introActive
|
||
|| _actionPrompt == null
|
||
|| _view == null
|
||
|| _view.IsFinished)
|
||
{
|
||
HideActionPrompt();
|
||
return;
|
||
}
|
||
|
||
string title = null;
|
||
string hint = null;
|
||
|
||
if (_discardChoiceActive)
|
||
{
|
||
title = "СБРОС КАРТЫ";
|
||
hint = string.IsNullOrWhiteSpace(_discardPrompt)
|
||
? "Выберите карту в руке для сброса."
|
||
: _discardPrompt;
|
||
}
|
||
else if (_pendingAbilityCost != null)
|
||
{
|
||
title = "ОПЛАТА";
|
||
if (RequiresCounterBlast(_pendingAbilityCost))
|
||
{
|
||
var required = CounterBlastAmount(_pendingAbilityCost);
|
||
hint = "Выберите карты урона для Counter Blast: "
|
||
+ _abilityDamageIndexes.Count + " / " + required + ".";
|
||
if (_abilityDamageIndexes.Count >= required)
|
||
hint = "Оплата выбрана. Подтвердите в панели действий.";
|
||
}
|
||
else
|
||
{
|
||
hint = "Оплата выбрана. Подтвердите в панели действий.";
|
||
}
|
||
}
|
||
else if (_pendingBoosterSlot >= 0 && _pendingIntents.Count > 0)
|
||
{
|
||
title = "ПОДДЕРЖКА";
|
||
hint = "Атакуйте только поддерживаемой картой и выберите цель.";
|
||
}
|
||
else if (_pendingIntents.Count > 0)
|
||
{
|
||
if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent)
|
||
&& _view.Trigger != null)
|
||
{
|
||
if (!_triggerSelectionReady)
|
||
{
|
||
title = "ТРИГГЕР";
|
||
hint = "Проверяем карту…";
|
||
}
|
||
else if (_triggerPowerSlot == null)
|
||
{
|
||
title = "ТРИГГЕР: СИЛА";
|
||
hint = "Выберите свою карту в RC или VC для +5000.";
|
||
}
|
||
else
|
||
{
|
||
title = "ТРИГГЕР: ЭФФЕКТ";
|
||
hint = TriggerEffectHint();
|
||
}
|
||
}
|
||
else
|
||
{
|
||
title = "ВЫБОР ЦЕЛИ";
|
||
hint = _view.Trigger != null
|
||
? "Выберите цель силы или эффекта триггера."
|
||
: "Выберите цель на поле.";
|
||
}
|
||
}
|
||
else if (_view.Ability != null && _view.ActivePlayer == _view.You)
|
||
{
|
||
title = "СПОСОБНОСТЬ";
|
||
hint = "Разыграть способность «" + CardName(_view.Ability.PrintingId) + "»?";
|
||
}
|
||
else if (_view.Order != null && _view.ActivePlayer == _view.You)
|
||
{
|
||
title = "ПОРЯДОК ТРИГГЕРОВ";
|
||
hint = "Выберите, какой эффект разрешить первым.";
|
||
}
|
||
else if (_view.Trigger != null && _view.ActivePlayer == _view.You)
|
||
{
|
||
title = "ТРИГГЕР";
|
||
hint = _triggerSelectionReady
|
||
? TriggerEffectHint()
|
||
: "Проверяем карту…";
|
||
}
|
||
else if (_view.Choice != null && _view.ActivePlayer == _view.You)
|
||
{
|
||
title = IsRearGuardChoice()
|
||
? "ВЫБОР КРУГА"
|
||
: IsHandChoice() || IsCardChoice() ? "ВЫБОР КАРТЫ" : "ВЫБОР";
|
||
hint = _view.Choice.Prompt;
|
||
}
|
||
else if (_view.ActivePlayer != _view.You)
|
||
{
|
||
HideActionPrompt();
|
||
return;
|
||
}
|
||
else if (_view.Phase == GamePhase.Mulligan)
|
||
{
|
||
title = "МУЛИГАН";
|
||
hint = _mulliganSelected.Count == 0
|
||
? "Выберите карты в руке, затем замените их или закончите муллиган."
|
||
: "Выбрано карт: " + _mulliganSelected.Count
|
||
+ ". Нажмите «Заменить " + _mulliganSelected.Count
|
||
+ " карт» или закончите муллиган.";
|
||
}
|
||
else if (_view.Phase == GamePhase.Ride)
|
||
{
|
||
title = "СМЕНА";
|
||
hint = "Выберите карту для смены или пропустите смену.";
|
||
}
|
||
else if (_view.Step == GameStep.Guard)
|
||
{
|
||
title = "ЗАЩИТА";
|
||
var guardCount = _guardSelected.Count
|
||
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
|
||
hint = guardCount == 0
|
||
? "Выбрано карт: "
|
||
: ". Щит: +" + guardCount
|
||
+ "." + GuardSelectionShield() + ".";
|
||
}
|
||
else if (_view.Phase == GamePhase.Battle)
|
||
{
|
||
title = "БОЙ";
|
||
hint = "Выберите атакующего или завершите ход.";
|
||
}
|
||
else
|
||
{
|
||
HideActionPrompt();
|
||
return;
|
||
}
|
||
|
||
var key = (title ?? string.Empty) + "\n" + (hint ?? string.Empty);
|
||
if (string.Equals(_actionPromptKey, key, StringComparison.Ordinal)
|
||
&& _actionPrompt.gameObject.activeSelf)
|
||
return;
|
||
|
||
_actionPromptKey = key;
|
||
_actionPromptTitle.text = title ?? string.Empty;
|
||
_actionPromptHint.text = hint ?? string.Empty;
|
||
_actionPrompt.gameObject.SetActive(true);
|
||
}
|
||
|
||
private void HideActionPrompt()
|
||
{
|
||
if (_actionPrompt == null)
|
||
return;
|
||
|
||
_actionPromptKey = null;
|
||
_actionPrompt.gameObject.SetActive(false);
|
||
}
|
||
|
||
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(190f, 220f);
|
||
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
|
||
|| _discardChoiceActive)
|
||
{
|
||
_phasePanel.gameObject.SetActive(false);
|
||
return;
|
||
}
|
||
|
||
var isGuardStep = IsGuardStep();
|
||
var isAbilityCost = _pendingAbilityCost != null;
|
||
var intents = (view.LegalIntents ?? new List<IntentPayload>())
|
||
.Where(IsPhaseIntent)
|
||
.Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1))
|
||
// The exchange is followed by an automatic finish; showing an
|
||
// inactive finish button after the exchange is misleading.
|
||
.Where(intent => !(intent is FinishMulliganIntent && _mulliganUsed))
|
||
.ToList();
|
||
if (intents.Count == 0 && !isGuardStep && !isAbilityCost)
|
||
{
|
||
_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);
|
||
|
||
if (isGuardStep)
|
||
AddGuardPhaseButton();
|
||
if (isAbilityCost)
|
||
{
|
||
AddAbilityCostPhaseButton();
|
||
return;
|
||
}
|
||
|
||
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 void AddGuardPhaseButton()
|
||
{
|
||
var count = _guardSelected.Count
|
||
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
|
||
var button = UiKit.CreateButton(
|
||
_phasePanel,
|
||
"Phase_Guard",
|
||
count == 0 ? "Не защищаться" : "Защищаться",
|
||
() =>
|
||
{
|
||
if (_guardSelected.Count == 0
|
||
&& _guardSelectedInterceptSlot < 0)
|
||
{
|
||
OnIntentClicked(new PassGuardIntent());
|
||
}
|
||
else
|
||
{
|
||
ConfirmGuardSelection();
|
||
}
|
||
},
|
||
14);
|
||
button.interactable = !_inputLock.IsLocked && !_sending;
|
||
UiKit.SetPreferredHeight(button, 40f);
|
||
}
|
||
|
||
private void AddAbilityCostPhaseButton()
|
||
{
|
||
var button = UiKit.CreateButton(
|
||
_phasePanel,
|
||
"Phase_ConfirmAbilityCost",
|
||
"Подтвердить оплату",
|
||
ConfirmAbilityCost,
|
||
14);
|
||
button.interactable = !_inputLock.IsLocked
|
||
&& !_sending
|
||
&& IsAbilityCostReady();
|
||
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 PlayerStatsPanelView();
|
||
_selfZones.Build(
|
||
_root,
|
||
"PlayerStatsSelf",
|
||
true,
|
||
new Vector2(0f, 0f),
|
||
new Vector2(0f, 0f),
|
||
new Vector2(24f, 24f),
|
||
new Vector2(360f, 86f));
|
||
_selfZones.ZoneClicked += zone => OpenZone(zone, _view?.You ?? 0);
|
||
|
||
_opponentZones = new PlayerStatsPanelView();
|
||
_opponentZones.Build(
|
||
_root,
|
||
"PlayerStatsOpponent",
|
||
false,
|
||
new Vector2(1f, 1f),
|
||
new Vector2(1f, 1f),
|
||
new Vector2(-24f, -88f),
|
||
new Vector2(360f, 86f));
|
||
_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, -86f);
|
||
_combatStats.sizeDelta = new Vector2(620f, 46f);
|
||
var statsImage = _combatStats.GetComponent<Image>();
|
||
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 + GuardSelectionShield();
|
||
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, true);
|
||
_opponentZones?.Update(opponent, 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 player, int displayIndex)
|
||
{
|
||
if (_inputLock.IsLocked || _view == null || player != _view.You)
|
||
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;
|
||
|
||
if (!_abilityDamageIndexes.Remove(displayIndex))
|
||
_abilityDamageIndexes.Add(displayIndex);
|
||
_abilityDamageIndex = _abilityDamageIndexes.Count > 0
|
||
? _abilityDamageIndexes.Min()
|
||
: -1;
|
||
SetStatus(
|
||
"Выбрано карт урона: " + _abilityDamageIndexes.Count + " / " + CounterBlastAmount(_pendingAbilityCost) + ".",
|
||
new Color(0.45f, 0.95f, 0.62f));
|
||
Render(animateMoves: false);
|
||
}
|
||
|
||
private string CardName(string printingId) =>
|
||
_catalog.TryGetValue(printingId, out var meta) ? Loc.CardName(meta) : printingId;
|
||
|
||
private string TriggerEffectLabel() => _view?.Trigger?.Trigger switch
|
||
{
|
||
TriggerKind.Critical => "Крит +1",
|
||
TriggerKind.Stand => "Поднять юнита",
|
||
TriggerKind.Draw => "Взять карту",
|
||
TriggerKind.Heal => "Лечить",
|
||
_ => "эффект",
|
||
};
|
||
|
||
private string TriggerEffectHint() => _view?.Trigger?.Trigger switch
|
||
{
|
||
TriggerKind.Critical => "Крит +1: выберите свою карту в RC или VC.",
|
||
TriggerKind.Stand => "Подъём: выберите повёрнутую карту в RC.",
|
||
TriggerKind.Draw => "Добор: карта добавляется автоматически.",
|
||
TriggerKind.Heal => "Лечение: выберите карту урона, если условия позволяют.",
|
||
_ => "Выберите, как применить эффект триггера.",
|
||
};
|
||
|
||
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);
|
||
|
||
Render(animateMoves: false);
|
||
}
|
||
|
||
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;
|
||
if (_view != null)
|
||
RebuildIntents(_view);
|
||
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<IntentPayload>();
|
||
|
||
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<AttackIntent>()
|
||
.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<GuardIntent>().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);
|
||
if (sources[0] is ActivateAbilityIntent ability)
|
||
ActivateAbility(ability);
|
||
else
|
||
OnIntentClicked(sources[0]);
|
||
return;
|
||
}
|
||
|
||
var withTargets = sources
|
||
.Where(intent => TryGetTarget(intent, out _))
|
||
.ToList();
|
||
|
||
if (withTargets.Count == 0 && sources.Count > 1)
|
||
{
|
||
var abilities = sources.OfType<ActivateAbilityIntent>().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);
|
||
if (sources[0] is ActivateAbilityIntent ability)
|
||
ActivateAbility(ability);
|
||
else
|
||
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;
|
||
_pendingBoostAttackerSlot = -2;
|
||
_triggerPowerSlot = null;
|
||
if (rebuild && _view != null)
|
||
RebuildIntents(_view);
|
||
RefreshBoardHighlights();
|
||
}
|
||
|
||
private void PrepareTriggerSelection()
|
||
{
|
||
if (_view?.Trigger == null || _view.ActivePlayer != _view.You)
|
||
{
|
||
_triggerSelectionReady = false;
|
||
return;
|
||
}
|
||
|
||
var triggers = (_view.LegalIntents ?? new List<IntentPayload>())
|
||
.OfType<ResolveTriggerIntent>()
|
||
.ToList();
|
||
if (triggers.Count == 0)
|
||
{
|
||
_triggerSelectionReady = false;
|
||
return;
|
||
}
|
||
|
||
_pendingIntents.Clear();
|
||
_pendingIntents.AddRange(triggers);
|
||
_triggerPowerSlot = null;
|
||
_triggerSelectionReady = true;
|
||
RebuildHighlights(animateMoves: true);
|
||
}
|
||
|
||
private bool HandleTriggerTarget(BoardTarget target)
|
||
{
|
||
if (_view?.Trigger == null || target.Player != _view.You)
|
||
return false;
|
||
|
||
var triggers = _pendingIntents.OfType<ResolveTriggerIntent>().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);
|
||
|
||
// Draw and Heal have no second board target: Draw resolves
|
||
// immediately, Heal asks for a damage card on the server only
|
||
// when its conditions are met.
|
||
if (_view.Trigger.Trigger is TriggerKind.Draw or TriggerKind.Heal)
|
||
{
|
||
var trigger = powerTargets[0];
|
||
ClearSelection(rebuild: false);
|
||
OnIntentClicked(trigger);
|
||
return true;
|
||
}
|
||
|
||
RebuildHighlights();
|
||
RebuildIntents(_view);
|
||
SetStatus(
|
||
TriggerEffectHint(),
|
||
new Color(0.98f, 0.82f, 0.42f));
|
||
return true;
|
||
}
|
||
|
||
var effect = triggers.FirstOrDefault(trigger => trigger.EffectTarget == target.Slot);
|
||
if (effect == null)
|
||
{
|
||
if (_view.Trigger.Trigger == TriggerKind.Stand)
|
||
{
|
||
SetStatus(
|
||
"Поднять можно только повёрнутую карту в RC.",
|
||
new Color(1f, 0.55f, 0.45f));
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
ClearSelection(rebuild: false);
|
||
OnIntentClicked(effect);
|
||
return true;
|
||
}
|
||
|
||
private void RebuildHighlights(bool animateMoves = false)
|
||
{
|
||
_highlightSlots.Clear();
|
||
|
||
if (_view == null)
|
||
return;
|
||
|
||
if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent))
|
||
{
|
||
foreach (var trigger in _pendingIntents.OfType<ResolveTriggerIntent>())
|
||
{
|
||
var slot = _triggerPowerSlot == null
|
||
? trigger.PowerTarget
|
||
: trigger.EffectTarget;
|
||
_highlightSlots.Add(new BoardSlotKey(_view.You, slot));
|
||
}
|
||
|
||
RefreshBoardHighlights(animateMoves);
|
||
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(animateMoves);
|
||
}
|
||
|
||
private void RefreshBoardHighlights(bool animateMoves = false)
|
||
{
|
||
if (_view != null && _board3D != null)
|
||
_board3D.Render(_view, BuildBoardRenderState(), animateMoves);
|
||
}
|
||
|
||
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);
|
||
if (source.InstanceId == 0)
|
||
{
|
||
var hand = _view.Players
|
||
.FirstOrDefault(player => player.Index == _view.You)
|
||
?.Hand;
|
||
var handCard = hand?.FirstOrDefault(
|
||
card => card.InstanceId == activate.InstanceId);
|
||
if (handCard != null)
|
||
{
|
||
source = new BoardTarget(
|
||
_view.You, -2, handCard.PrintingId,
|
||
handCard.InstanceId, true);
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
if (_intents == null || view == null)
|
||
return;
|
||
|
||
ClearChildren(_intents);
|
||
if (_introActive)
|
||
{
|
||
_confirmationBar?.gameObject.SetActive(false);
|
||
return;
|
||
}
|
||
|
||
if (_confirmationBar != null)
|
||
_confirmationBar.gameObject.SetActive(true);
|
||
|
||
if (_discardChoiceActive)
|
||
{
|
||
BuildDiscardPrompt();
|
||
return;
|
||
}
|
||
|
||
var legal = view.LegalIntents ?? new List<IntentPayload>();
|
||
var playable = legal
|
||
.Where(intent => intent is not ConcedeIntent)
|
||
.Where(intent => !(intent is FinishMulliganIntent && _mulliganUsed))
|
||
.ToList();
|
||
var global = playable
|
||
.Where(intent => !IsBoardIntent(intent)
|
||
&& intent is not PassGuardIntent
|
||
&& intent is not ActivateAbilityIntent
|
||
&& intent is not ResolveAbilityIntent
|
||
&& !(intent is ChooseIntent
|
||
&& (IsRearGuardChoice() || IsHandChoice() || IsCardChoice()))
|
||
&& !(intent is EndTurnIntent && view.Phase == GamePhase.Main))
|
||
.ToList();
|
||
|
||
if (_pendingIntents.Count > 0)
|
||
{
|
||
var prompt = UiKit.CreateText(
|
||
_intents,
|
||
"TargetPrompt",
|
||
_pendingIntents.Any(intent => intent is ResolveTriggerIntent)
|
||
? (_triggerPowerSlot == null
|
||
? "Триггер: выберите свою карту в RC или VC для +5000."
|
||
: TriggerEffectHint())
|
||
: _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(animateMoves: false);
|
||
}, 17);
|
||
UiKit.SetPreferredHeight(cancel, 44f);
|
||
return;
|
||
}
|
||
|
||
if (playable.Count == 0)
|
||
{
|
||
_confirmationBar?.gameObject.SetActive(false);
|
||
return;
|
||
}
|
||
|
||
if (global.Count == 0
|
||
&& !IsAbilityDamageTargeting()
|
||
&& !IsGuardStep()
|
||
&& _pendingAbilityCost == null)
|
||
{
|
||
_confirmationBar?.gameObject.SetActive(false);
|
||
return;
|
||
}
|
||
|
||
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 (IsGuardStep())
|
||
AddGuardConfirmationButton();
|
||
|
||
if (_pendingAbilityCost != null)
|
||
AddAbilityCostConfirmationButton();
|
||
|
||
foreach (var intent in global)
|
||
{
|
||
if (intent is FinishMulliganIntent && _mulliganSelected.Count > 0)
|
||
{
|
||
var exchange = UiKit.CreateButton(
|
||
_intents,
|
||
"MulliganExchange",
|
||
"Заменить " + _mulliganSelected.Count + " карт",
|
||
SendMulligan,
|
||
15);
|
||
exchange.interactable = !_inputLock.IsLocked && !_sending;
|
||
UiKit.SetPreferredHeight(exchange, 42f);
|
||
continue;
|
||
}
|
||
|
||
AddIntentButton(intent);
|
||
}
|
||
}
|
||
|
||
private void AddGuardConfirmationButton()
|
||
{
|
||
var count = _guardSelected.Count
|
||
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
|
||
var button = UiKit.CreateButton(
|
||
_intents,
|
||
"Phase_Guard",
|
||
count == 0 ? "Не защищаться" : "Защищаться",
|
||
() =>
|
||
{
|
||
if (_guardSelected.Count == 0
|
||
&& _guardSelectedInterceptSlot < 0)
|
||
{
|
||
OnIntentClicked(new PassGuardIntent());
|
||
}
|
||
else
|
||
{
|
||
ConfirmGuardSelection();
|
||
}
|
||
},
|
||
15);
|
||
button.interactable = !_inputLock.IsLocked && !_sending;
|
||
UiKit.SetPreferredHeight(button, 42f);
|
||
}
|
||
|
||
private void AddAbilityCostConfirmationButton()
|
||
{
|
||
var button = UiKit.CreateButton(
|
||
_intents,
|
||
"Phase_ConfirmAbilityCost",
|
||
"Подтвердить оплату",
|
||
ConfirmAbilityCost,
|
||
15);
|
||
button.interactable = !_inputLock.IsLocked
|
||
&& !_sending
|
||
&& IsAbilityCostReady();
|
||
UiKit.SetPreferredHeight(button, 42f);
|
||
}
|
||
|
||
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)
|
||
{
|
||
_clock.text = "—";
|
||
_bank.text = "Банк —";
|
||
return;
|
||
}
|
||
|
||
var bank = _view.Clock.DecisionPlayer == _view.You
|
||
? _view.Clock.Bank0Ms
|
||
: _view.Clock.Bank1Ms;
|
||
_bank.text = "Банк " + Format(bank);
|
||
|
||
if (!_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);
|
||
_clock.text = Format(remaining);
|
||
}
|
||
|
||
private void UpdateTurnHud()
|
||
{
|
||
if (_topInfo == null)
|
||
return;
|
||
|
||
if (_view == null)
|
||
{
|
||
_topInfo.text = "Turn —";
|
||
UiKit.MakeLiteral(_topInfo);
|
||
return;
|
||
}
|
||
|
||
var phases = new[]
|
||
{
|
||
(Label: "Standby", Phase: GamePhase.Stand),
|
||
(Label: "Main", Phase: GamePhase.Main),
|
||
(Label: "Battle", Phase: GamePhase.Battle),
|
||
(Label: "End", Phase: GamePhase.End),
|
||
};
|
||
var stepper = string.Join(
|
||
" › ",
|
||
phases.Select(item => item.Phase == _view.Phase
|
||
? "<color=#FFD166><b>" + item.Label + "</b></color>"
|
||
: "<color=#67728C>" + item.Label + "</color>"));
|
||
|
||
var step = _view.Step switch
|
||
{
|
||
GameStep.Guard => " · Защита",
|
||
GameStep.Drive => " · Drive",
|
||
GameStep.Trigger => " · Триггер",
|
||
GameStep.Ability => " · Способность",
|
||
GameStep.Choice => " · Выбор",
|
||
_ => string.Empty,
|
||
};
|
||
|
||
var prefix = _view.Phase == GamePhase.Mulligan
|
||
? "Муллиган"
|
||
: "Turn " + _view.TurnNumber + " " + stepper;
|
||
_topInfo.text = prefix + step;
|
||
UiKit.MakeLiteral(_topInfo);
|
||
}
|
||
|
||
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:
|
||
if (trigger.EffectTarget < 0)
|
||
return "Триггер: +5000 → " + SlotName(trigger.PowerTarget);
|
||
return "Триггер: +5000 → " + SlotName(trigger.PowerTarget)
|
||
+ ", " + TriggerEffectLabel() + " > "
|
||
+ 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)
|
||
{
|
||
if (_status == null)
|
||
return;
|
||
|
||
var value = text ?? string.Empty;
|
||
_status.text = value;
|
||
_status.color = color;
|
||
_status.gameObject.SetActive(!string.IsNullOrWhiteSpace(value));
|
||
_statusHideAt = Time.unscaledTime + 4f;
|
||
}
|
||
|
||
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)
|
||
&& (InstanceId == 0
|
||
|| other.InstanceId == 0
|
||
|| InstanceId == other.InstanceId);
|
||
}
|
||
|
||
return Player == other.Player && Slot == other.Slot;
|
||
}
|
||
}
|
||
}
|
||
}
|