2926 lines
106 KiB
C#
2926 lines
106 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 bool _feedbackLockHeld;
|
||
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
|
||
new Dictionary<string, CardCatalogItemDto>(StringComparer.Ordinal);
|
||
private bool _catalogLoading;
|
||
|
||
private readonly RectTransform _root;
|
||
private RectTransform _intents;
|
||
private readonly Text _topInfo;
|
||
private readonly Text _clock;
|
||
private readonly Text _status;
|
||
private readonly Button _concede;
|
||
private readonly Text _concedeLabel;
|
||
private readonly Button _reconnect;
|
||
private readonly RectTransform _result;
|
||
private RectTransform _logContent;
|
||
private Text _resultText;
|
||
private Text _feedback;
|
||
private Image _flash;
|
||
private float _feedbackTime;
|
||
private float _feedbackDuration;
|
||
private Color _feedbackColor;
|
||
private readonly Queue<FeedbackStep> _feedbackQueue = new Queue<FeedbackStep>();
|
||
private Button _skipFeedback;
|
||
private bool _feedbackPlaying;
|
||
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 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? _triggerPowerSlot;
|
||
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
|
||
private bool _mulliganUsed;
|
||
private int _abilityDamageIndex = -1;
|
||
|
||
private readonly HashSet<BoardSlotKey> _highlightSlots = new HashSet<BoardSlotKey>();
|
||
private GameBoard3D _board3D;
|
||
private OverlayNavigationService _overlays;
|
||
private ZoneBlockStripView _selfZones;
|
||
private ZoneBlockStripView _opponentZones;
|
||
private CardActionMenuView _cardMenu;
|
||
private RectTransform _intentPanel;
|
||
private Text _intentPanelToggleLabel;
|
||
private bool _intentPanelVisible = true;
|
||
private RectTransform _phasePanel;
|
||
private RectTransform _combatStats;
|
||
private Text _combatStatsText;
|
||
private bool _autoFinishMulligan;
|
||
private float _autoFinishMulliganAt;
|
||
private bool _autoEndBattleArmed;
|
||
private float _autoEndBattleAt;
|
||
|
||
public BattleView(Transform parent, MetaService meta, Action onExit)
|
||
{
|
||
_meta = meta;
|
||
_onExit = onExit;
|
||
_animationConfig = AnimationConfig.LoadOrDefault();
|
||
_inputLock = new InputLockService();
|
||
_inputLock.Changed += OnInputLockChanged;
|
||
|
||
_root = UiKit.CreatePanel(parent, "BattleScreen", Background);
|
||
var rootImage = _root.GetComponent<Image>();
|
||
if (rootImage != null)
|
||
{
|
||
rootImage.color = Color.clear;
|
||
rootImage.raycastTarget = false;
|
||
}
|
||
|
||
var top = UiKit.CreatePanel(_root, "TopBar", Panel);
|
||
top.anchorMin = new Vector2(0f, 1f);
|
||
top.anchorMax = new Vector2(1f, 1f);
|
||
top.pivot = new Vector2(0.5f, 1f);
|
||
top.anchoredPosition = Vector2.zero;
|
||
top.sizeDelta = new Vector2(0f, 88f);
|
||
UiKit.AddHorizontalLayout(top, 20, 14);
|
||
|
||
var title = UiKit.CreateText(top, "Title", "Бой", 32, Color.white, TextAnchor.MiddleLeft);
|
||
title.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(title, 46f, 0f);
|
||
|
||
_topInfo = UiKit.CreateText(
|
||
top, "Match", string.Empty, 17, Muted, TextAnchor.MiddleLeft);
|
||
UiKit.MakeLiteral(_topInfo);
|
||
UiKit.SetPreferredHeight(_topInfo, 38f);
|
||
|
||
_clock = UiKit.CreateText(
|
||
top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight);
|
||
UiKit.SetPreferredHeight(_clock, 44f, 0f);
|
||
|
||
var toggle = UiKit.CreateButton(
|
||
top,
|
||
"ToggleIntentPanel",
|
||
"Скрыть панель",
|
||
ToggleIntentPanel,
|
||
16);
|
||
var toggleElement = UiKit.SetPreferredHeight(toggle, 44f, 0f);
|
||
toggleElement.preferredWidth = 168f;
|
||
_intentPanelToggleLabel = toggle.GetComponentInChildren<Text>();
|
||
UiKit.MakeLiteral(_intentPanelToggleLabel);
|
||
|
||
_concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18);
|
||
_concedeLabel = _concede.GetComponentInChildren<Text>();
|
||
UiKit.SetPreferredHeight(_concede, 44f, 0f);
|
||
|
||
var right = UiKit.CreatePanel(_root, "IntentPanel", Panel);
|
||
_intentPanel = right;
|
||
right.anchorMin = new Vector2(1f, 0f);
|
||
right.anchorMax = new Vector2(1f, 1f);
|
||
right.pivot = new Vector2(1f, 0.5f);
|
||
right.offsetMin = new Vector2(-456f, 28f);
|
||
right.offsetMax = new Vector2(-36f, -108f);
|
||
UiKit.AddVerticalLayout(right, 14, 10);
|
||
|
||
var actionsTitle = UiKit.CreateText(
|
||
right, "ActionsTitle", "Действия", 24, Color.white, TextAnchor.MiddleLeft);
|
||
actionsTitle.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(actionsTitle, 32f);
|
||
|
||
var scroll = UiKit.CreateScrollView(right, "IntentScroll", out _intents, 6f);
|
||
var scrollElement = scroll.gameObject.AddComponent<LayoutElement>();
|
||
scrollElement.flexibleHeight = 1f;
|
||
scrollElement.minHeight = 200f;
|
||
|
||
_status = UiKit.CreateText(
|
||
right, "Status", string.Empty, 16, Muted, TextAnchor.UpperLeft);
|
||
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
UiKit.SetPreferredHeight(_status, 74f);
|
||
|
||
_skipFeedback = UiKit.CreateButton(
|
||
right, "SkipFeedback", "Пропустить анимации", SkipFeedback, 15);
|
||
UiKit.SetPreferredHeight(_skipFeedback, 38f);
|
||
_skipFeedback.gameObject.SetActive(false);
|
||
|
||
var logTitle = UiKit.CreateText(
|
||
right, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
|
||
logTitle.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(logTitle, 22f);
|
||
|
||
var logScroll = UiKit.CreateScrollView(right, "LogScroll", out _logContent, 3f);
|
||
var logElement = logScroll.gameObject.AddComponent<LayoutElement>();
|
||
logElement.preferredHeight = 142f;
|
||
logElement.minHeight = 80f;
|
||
|
||
_reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18);
|
||
UiKit.SetPreferredHeight(_reconnect, 46f);
|
||
_reconnect.gameObject.SetActive(false);
|
||
|
||
_result = BuildResultPanel(_root);
|
||
_result.gameObject.SetActive(false);
|
||
|
||
_flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent<Image>();
|
||
_flash.raycastTarget = false;
|
||
_flash.gameObject.SetActive(false);
|
||
|
||
_feedback = UiKit.CreateText(
|
||
_root, "BattleFeedback", string.Empty, 44,
|
||
Color.white, TextAnchor.MiddleCenter);
|
||
_feedback.fontStyle = FontStyle.Bold;
|
||
_feedback.raycastTarget = false;
|
||
var feedbackRect = _feedback.rectTransform;
|
||
feedbackRect.anchorMin = new Vector2(0.5f, 0.5f);
|
||
feedbackRect.anchorMax = new Vector2(0.5f, 0.5f);
|
||
feedbackRect.pivot = new Vector2(0.5f, 0.5f);
|
||
feedbackRect.sizeDelta = new Vector2(900f, 120f);
|
||
feedbackRect.anchoredPosition = new Vector2(0f, 130f);
|
||
_feedback.gameObject.SetActive(false);
|
||
|
||
BuildOverlaysAndZones();
|
||
BuildPhasePanel();
|
||
BuildCombatStats();
|
||
BuildReconnectOverlay();
|
||
_cardMenu = new CardActionMenuView();
|
||
_cardMenu.Build(_root);
|
||
|
||
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
|
||
_board3D.ConfigureRuntime(_animationConfig, _inputLock);
|
||
_board3D.ArtProvider = ResolveBoardArt;
|
||
_board3D.CardClicked += OnBoard3DCardClicked;
|
||
_board3D.CardRightClicked += OnBoard3DCardRightClicked;
|
||
_board3D.SlotClicked += OnBoard3DSlotClicked;
|
||
_board3D.CardDropped += OnBoard3DCardDropped;
|
||
_board3D.PileClicked += OnBoard3DPileClicked;
|
||
_board3D.PileRightClicked += OnBoard3DPileRightClicked;
|
||
_board3D.SetVisible(false);
|
||
|
||
_root.gameObject.SetActive(false);
|
||
}
|
||
|
||
public void Show(MatchConnectionInfo info)
|
||
{
|
||
_info = info;
|
||
CancelReconnect();
|
||
_view = null;
|
||
_finished = false;
|
||
_confirmConcede = false;
|
||
_concedeLabel.text = "Сдаться";
|
||
_concede.interactable = true;
|
||
_reconnect.gameObject.SetActive(false);
|
||
_result.gameObject.SetActive(false);
|
||
_pendingIntents.Clear();
|
||
_discardChoiceActive = false;
|
||
_discardAbilityChoices.Clear();
|
||
_discardGuardChoices.Clear();
|
||
_discardCancelAction = null;
|
||
_discardPrompt = null;
|
||
_highlightSlots.Clear();
|
||
_mulliganSelected.Clear();
|
||
_mulliganUsed = false;
|
||
_abilityDamageIndex = -1;
|
||
_pendingBoosterSlot = -1;
|
||
_triggerPowerSlot = null;
|
||
_showAllIntents = false;
|
||
_autoFinishMulligan = false;
|
||
_autoFinishMulliganAt = 0f;
|
||
_autoEndBattleArmed = false;
|
||
_autoEndBattleAt = 0f;
|
||
_intentPanelVisible = true;
|
||
if (_intentPanel != null)
|
||
_intentPanel.gameObject.SetActive(true);
|
||
if (_intentPanelToggleLabel != null)
|
||
{
|
||
_intentPanelToggleLabel.text = "Скрыть панель";
|
||
UiKit.MakeLiteral(_intentPanelToggleLabel);
|
||
}
|
||
ApplyIntentPanelLayout();
|
||
_inputLock.ReleaseAll();
|
||
_feedbackLockHeld = false;
|
||
_logLines.Clear();
|
||
RebuildLog();
|
||
_feedbackQueue.Clear();
|
||
_feedbackPlaying = false;
|
||
_feedbackTime = 0f;
|
||
_feedback.gameObject.SetActive(false);
|
||
_flash.gameObject.SetActive(false);
|
||
_skipFeedback.gameObject.SetActive(false);
|
||
_combatStats?.gameObject.SetActive(false);
|
||
_board3D?.Clear();
|
||
_board3D?.SetVisible(true);
|
||
_overlays?.CloseAll();
|
||
_selfZones?.Update(null, self: true);
|
||
_opponentZones?.Update(null, self: false);
|
||
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
|
||
SetStatus("Подключаемся к матчу…", Muted);
|
||
_root.gameObject.SetActive(true);
|
||
_ = LoadCatalogAsync();
|
||
_ = ConnectAsync();
|
||
}
|
||
|
||
public void Hide()
|
||
{
|
||
CancelReconnect();
|
||
_cts?.Cancel();
|
||
_cts?.Dispose();
|
||
_cts = null;
|
||
|
||
if (_connection != null)
|
||
{
|
||
var connection = _connection;
|
||
_connection = null;
|
||
DetachConnection(connection);
|
||
_ = connection.DisposeAsync().AsTask();
|
||
}
|
||
|
||
_view = null;
|
||
_root.gameObject.SetActive(false);
|
||
_result.gameObject.SetActive(false);
|
||
_feedback.gameObject.SetActive(false);
|
||
_flash.gameObject.SetActive(false);
|
||
_skipFeedback.gameObject.SetActive(false);
|
||
_overlays?.CloseAll();
|
||
_cardMenu?.Hide();
|
||
_combatStats?.gameObject.SetActive(false);
|
||
_board3D?.SetVisible(false);
|
||
ReleaseFeedbackLock();
|
||
_inputLock.ReleaseAll();
|
||
}
|
||
|
||
public void Tick()
|
||
{
|
||
if (_confirmConcede && Time.unscaledTime > _confirmDeadline)
|
||
{
|
||
_confirmConcede = false;
|
||
_concedeLabel.text = "Сдаться";
|
||
}
|
||
|
||
UpdateClock();
|
||
UpdateFeedback();
|
||
UpdateAutoPhases();
|
||
}
|
||
|
||
private void UpdateAutoPhases()
|
||
{
|
||
if (_view == null || _sending || _inputLock.IsLocked)
|
||
return;
|
||
|
||
var legal = _view.LegalIntents ?? new List<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)
|
||
{
|
||
_autoFinishMulligan = false;
|
||
_autoFinishMulliganAt = 0f;
|
||
OnIntentClicked(finish);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var battle = _view.Phase == GamePhase.Battle
|
||
&& _view.ActivePlayer == _view.You
|
||
&& !_view.IsFinished;
|
||
var actionable = legal.Any(intent =>
|
||
intent is AttackIntent
|
||
or ActivateAbilityIntent
|
||
or ResolveAbilityIntent
|
||
or ResolveTriggerIntent
|
||
or SelectTriggerIntent
|
||
or ChooseIntent);
|
||
var endTurn = legal.OfType<EndTurnIntent>().FirstOrDefault();
|
||
|
||
if (battle && !actionable && endTurn != null && _pendingIntents.Count == 0)
|
||
{
|
||
if (!_autoEndBattleArmed)
|
||
{
|
||
_autoEndBattleArmed = true;
|
||
_autoEndBattleAt = Time.unscaledTime + 1.0f;
|
||
}
|
||
else if (Time.unscaledTime >= _autoEndBattleAt && !_feedbackPlaying)
|
||
{
|
||
_autoEndBattleArmed = false;
|
||
OnIntentClicked(endTurn);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
_autoEndBattleArmed = false;
|
||
}
|
||
}
|
||
|
||
private void ShowFeedback(string text, Color color, float duration = 1.25f, bool flash = false)
|
||
{
|
||
_feedbackQueue.Enqueue(new FeedbackStep(text, color, duration, flash));
|
||
if (!_feedbackPlaying)
|
||
StartNextFeedback();
|
||
}
|
||
|
||
private void StartNextFeedback()
|
||
{
|
||
if (_feedbackQueue.Count == 0)
|
||
{
|
||
_feedbackPlaying = false;
|
||
_skipFeedback.gameObject.SetActive(false);
|
||
ReleaseFeedbackLock();
|
||
return;
|
||
}
|
||
|
||
if (!_feedbackLockHeld)
|
||
{
|
||
_feedbackLockHeld = true;
|
||
_inputLock.Acquire("feedback");
|
||
}
|
||
|
||
var step = _feedbackQueue.Dequeue();
|
||
_feedbackPlaying = true;
|
||
_feedback.text = step.Text;
|
||
_feedbackColor = step.Color;
|
||
_feedbackDuration = step.Duration;
|
||
_feedbackTime = step.Duration;
|
||
_feedback.gameObject.SetActive(true);
|
||
_feedback.rectTransform.localScale = Vector3.one * 1.12f;
|
||
_skipFeedback.gameObject.SetActive(true);
|
||
|
||
if (step.Flash)
|
||
{
|
||
_flash.gameObject.SetActive(true);
|
||
_flash.color = new Color(step.Color.r, step.Color.g, step.Color.b, 0.22f);
|
||
}
|
||
}
|
||
|
||
private void ReleaseFeedbackLock()
|
||
{
|
||
if (!_feedbackLockHeld)
|
||
return;
|
||
|
||
_feedbackLockHeld = false;
|
||
_inputLock.Release("feedback");
|
||
}
|
||
|
||
private void OnInputLockChanged(bool locked)
|
||
{
|
||
if (_view == null)
|
||
return;
|
||
|
||
RebuildIntents(_view);
|
||
RebuildPhaseControls(_view);
|
||
}
|
||
|
||
private void OnOverlayOpenChanged(bool open)
|
||
{
|
||
if (open)
|
||
_inputLock.Acquire("overlay");
|
||
else
|
||
_inputLock.Release("overlay");
|
||
}
|
||
|
||
private void UpdateFeedback()
|
||
{
|
||
if (!_feedbackPlaying)
|
||
return;
|
||
|
||
_feedbackTime -= Time.unscaledDeltaTime;
|
||
var t = Mathf.Clamp01(_feedbackTime / Mathf.Max(0.01f, _feedbackDuration));
|
||
var alpha = Mathf.Clamp01(t * 1.8f);
|
||
_feedback.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha);
|
||
_feedback.rectTransform.localScale = Vector3.one * Mathf.Lerp(1f, 1.12f, t);
|
||
|
||
if (_flash != null && _flash.gameObject.activeSelf)
|
||
_flash.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha * 0.22f);
|
||
|
||
if (_feedbackTime > 0f)
|
||
return;
|
||
|
||
_feedback.gameObject.SetActive(false);
|
||
if (_flash != null)
|
||
_flash.gameObject.SetActive(false);
|
||
StartNextFeedback();
|
||
}
|
||
|
||
private void SkipFeedback()
|
||
{
|
||
_feedbackQueue.Clear();
|
||
_feedbackPlaying = false;
|
||
_feedbackTime = 0f;
|
||
_feedback.gameObject.SetActive(false);
|
||
_flash.gameObject.SetActive(false);
|
||
_skipFeedback.gameObject.SetActive(false);
|
||
ReleaseFeedbackLock();
|
||
}
|
||
|
||
private readonly struct FeedbackStep
|
||
{
|
||
public FeedbackStep(string text, Color color, float duration, bool flash)
|
||
{
|
||
Text = text;
|
||
Color = color;
|
||
Duration = duration;
|
||
Flash = flash;
|
||
}
|
||
|
||
public string Text { get; }
|
||
public Color Color { get; }
|
||
public float Duration { get; }
|
||
public bool Flash { get; }
|
||
}
|
||
|
||
private RectTransform BuildResultPanel(Transform parent)
|
||
{
|
||
var overlay = UiKit.CreatePanel(parent, "BattleResult", new Color(0f, 0f, 0f, 0.75f));
|
||
var card = UiKit.CreateCenteredCard(
|
||
overlay, "ResultCard", new Vector2(640f, 330f),
|
||
new Color(0.08f, 0.10f, 0.14f, 1f));
|
||
UiKit.AddVerticalLayout(card, 32, 14);
|
||
|
||
var title = UiKit.CreateText(card, "Title", "Матч завершён", 32, Color.white);
|
||
title.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(title, 48f);
|
||
|
||
_resultText = UiKit.CreateText(card, "Text", string.Empty, 22, Muted);
|
||
UiKit.SetPreferredHeight(_resultText, 90f);
|
||
|
||
var exit = UiKit.CreateButton(card, "Exit", "В меню", OnExitClicked, 21);
|
||
UiKit.SetPreferredHeight(exit, 52f);
|
||
|
||
return overlay;
|
||
}
|
||
|
||
private async Task LoadCatalogAsync()
|
||
{
|
||
if (_catalogLoading || _catalog.Count > 0)
|
||
return;
|
||
|
||
_catalogLoading = true;
|
||
try
|
||
{
|
||
var items = await _meta.GetCatalogAsync();
|
||
foreach (var item in items)
|
||
_catalog[item.PrintingId] = item;
|
||
|
||
if (_root.gameObject.activeSelf && _view != null)
|
||
Render();
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogWarning("Battle catalog load failed: " + exception.Message);
|
||
}
|
||
finally
|
||
{
|
||
_catalogLoading = false;
|
||
}
|
||
}
|
||
|
||
private async Task<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();
|
||
_triggerPowerSlot = null;
|
||
PrepareTriggerSelection();
|
||
if (_view.Clock != null)
|
||
{
|
||
_clockServerMs = _view.Clock.ServerTimeMs;
|
||
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
}
|
||
|
||
Render();
|
||
}
|
||
|
||
private void OnEvents(ServerEvents events)
|
||
{
|
||
if (events?.Events != null)
|
||
{
|
||
foreach (var envelope in events.Events)
|
||
{
|
||
if (envelope.Event is GameFinishedView)
|
||
_finished = true;
|
||
|
||
var line = Describe(envelope.Event);
|
||
if (!string.IsNullOrWhiteSpace(line))
|
||
_logLines.Add(line);
|
||
|
||
ShowFeedbackFor(envelope.Event);
|
||
}
|
||
|
||
while (_logLines.Count > 40)
|
||
_logLines.RemoveAt(0);
|
||
|
||
RebuildLog();
|
||
}
|
||
|
||
_ = RequestSnapshotAsync();
|
||
}
|
||
|
||
private void RebuildLog()
|
||
{
|
||
if (_logContent == null)
|
||
return;
|
||
|
||
ClearChildren(_logContent);
|
||
var start = Mathf.Max(0, _logLines.Count - 8);
|
||
for (var i = start; i < _logLines.Count; i++)
|
||
{
|
||
var line = UiKit.CreateText(
|
||
_logContent, "LogLine", _logLines[i], 13,
|
||
i == _logLines.Count - 1
|
||
? new Color(0.92f, 0.94f, 1f)
|
||
: new Color(0.60f, 0.68f, 0.82f),
|
||
TextAnchor.MiddleLeft);
|
||
line.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
UiKit.SetPreferredHeight(line, 30f);
|
||
}
|
||
}
|
||
|
||
private void ShowFeedbackFor(EventView view)
|
||
{
|
||
switch (view)
|
||
{
|
||
case AttackDeclaredView attack:
|
||
ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot)
|
||
+ " → " + SlotName(attack.TargetSlot),
|
||
new Color(1f, 0.82f, 0.35f), _animationConfig.attackDeclare);
|
||
break;
|
||
case GuardAddedView guard:
|
||
ShowFeedback("GUARD " + CardName(guard.PrintingId)
|
||
+ " +" + guard.Shield,
|
||
new Color(0.45f, 0.82f, 1f), _animationConfig.guard);
|
||
break;
|
||
case DriveCheckedView drive:
|
||
ShowFeedback("DRIVE " + CardName(drive.PrintingId)
|
||
+ " · " + TriggerShort(drive.Trigger.ToString()),
|
||
new Color(0.72f, 0.88f, 1f), _animationConfig.driveCheck);
|
||
break;
|
||
case TriggerResolvedView trigger:
|
||
ShowFeedback("TRIGGER " + CardName(trigger.PrintingId),
|
||
new Color(0.72f, 0.88f, 1f), _animationConfig.trigger);
|
||
break;
|
||
case BattleResolvedView battle:
|
||
ShowFeedback(battle.Hit ? "HIT" : "BLOCK",
|
||
battle.Hit ? new Color(1f, 0.45f, 0.35f) : new Color(0.45f, 0.82f, 1f),
|
||
_animationConfig.battleResolved);
|
||
break;
|
||
case DamageTakenView damage:
|
||
ShowFeedback("+" + damage.Amount + " DAMAGE",
|
||
new Color(1f, 0.30f, 0.26f), _animationConfig.damage, flash: true);
|
||
break;
|
||
case HealedView:
|
||
ShowFeedback("HEAL",
|
||
new Color(0.45f, 0.95f, 0.62f), _animationConfig.heal, flash: true);
|
||
break;
|
||
case UnitRetiredView:
|
||
ShowFeedback("RETIRE",
|
||
new Color(1f, 0.55f, 0.30f), _animationConfig.retireDuration);
|
||
break;
|
||
case InterceptedView intercept:
|
||
ShowFeedback("INTERCEPT " + CardName(intercept.PrintingId),
|
||
new Color(0.55f, 0.82f, 1f), _animationConfig.intercept);
|
||
break;
|
||
case PerfectGuardView perfect:
|
||
ShowFeedback("PERFECT GUARD " + CardName(perfect.SentinelPrintingId),
|
||
new Color(0.60f, 0.82f, 1f), _animationConfig.perfectGuard);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private string Describe(EventView view)
|
||
{
|
||
if (view == null)
|
||
return null;
|
||
|
||
return view switch
|
||
{
|
||
UnitRodeView ride => "Ride: " + CardName(ride.PrintingId),
|
||
UnitCalledView call => "Call: " + CardName(call.PrintingId) + " → " + SlotName(call.Slot),
|
||
UnitMovedView move => "Move: " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot),
|
||
AttackDeclaredView attack => "Атака: " + SlotName(attack.AttackerSlot)
|
||
+ " → " + SlotName(attack.TargetSlot),
|
||
GuardAddedView guard => "Guard: " + CardName(guard.PrintingId)
|
||
+ " (+" + guard.Shield + ")",
|
||
PerfectGuardView perfect => "Perfect Guard: " + CardName(perfect.SentinelPrintingId),
|
||
DriveCheckedView drive => "Drive: " + CardName(drive.PrintingId)
|
||
+ " · " + drive.Trigger,
|
||
TriggerResolvedView trigger => "Триггер: " + CardName(trigger.PrintingId),
|
||
BattleResolvedView battle => battle.Hit ? "Атака прошла." : "Атака отбита.",
|
||
DamageTakenView damage => "Урон: " + PlayerName(damage.Player)
|
||
+ " +" + damage.Amount + " (всего " + damage.Total + ")",
|
||
HealedView heal => "Heal: " + PlayerName(heal.Player),
|
||
UnitRetiredView retire => "Retire: " + SlotName(retire.Slot),
|
||
InterceptedView intercept => "Intercept: " + CardName(intercept.PrintingId),
|
||
CardDrawnView draw => "Карта: " + PlayerName(draw.Player),
|
||
PhaseChangedView phase => "Фаза: " + phase.Phase,
|
||
GameFinishedView finish => "Партия завершена.",
|
||
_ => null,
|
||
};
|
||
}
|
||
|
||
private string PlayerName(int player) =>
|
||
_view == null ? "P" + player : player == _view.You ? "Вы" : "Противник";
|
||
|
||
private void OnClock(ServerClock clock)
|
||
{
|
||
if (!_root.gameObject.activeSelf || clock?.Clock == null)
|
||
return;
|
||
|
||
_clockServerMs = clock.Clock.ServerTimeMs;
|
||
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
|
||
if (_view != null)
|
||
_view.Clock = clock.Clock;
|
||
|
||
UpdateClock();
|
||
}
|
||
|
||
private void OnError(ServerError error)
|
||
{
|
||
SetStatus(error.Code + ": " + error.Message, new Color(1f, 0.55f, 0.45f));
|
||
}
|
||
|
||
private void OnClosed(string reason)
|
||
{
|
||
if (!_root.gameObject.activeSelf || _finished || _view?.IsFinished == true)
|
||
return;
|
||
|
||
BeginReconnect(reason);
|
||
}
|
||
|
||
private void BeginReconnect(string reason)
|
||
{
|
||
if (_info == null
|
||
|| !_root.gameObject.activeSelf
|
||
|| _finished
|
||
|| _view?.IsFinished == true)
|
||
return;
|
||
|
||
if (_reconnecting)
|
||
{
|
||
SetReconnectHint("Соединение потеряно. Переподключаемся…");
|
||
SetReconnectReason(reason);
|
||
return;
|
||
}
|
||
|
||
_reconnecting = true;
|
||
_reconnectAttempt = 0;
|
||
_reconnectOverlay?.SetAsLastSibling();
|
||
_reconnectOverlay?.gameObject.SetActive(true);
|
||
SetReconnectHint("Переподключаемся…");
|
||
SetReconnectReason(reason);
|
||
_reconnect.gameObject.SetActive(false);
|
||
_inputLock.Acquire("reconnect");
|
||
|
||
_reconnectCts?.Cancel();
|
||
_reconnectCts?.Dispose();
|
||
_reconnectCts = new CancellationTokenSource();
|
||
_ = ReconnectLoopAsync(_reconnectCts.Token);
|
||
}
|
||
|
||
private async Task ReconnectLoopAsync(CancellationToken token)
|
||
{
|
||
var delays = new[] { 0f, 2f, 10f, 30f };
|
||
var attempt = 0;
|
||
|
||
while (!token.IsCancellationRequested
|
||
&& _root.gameObject.activeSelf
|
||
&& !_finished)
|
||
{
|
||
var delay = attempt < delays.Length ? delays[attempt] : 30f;
|
||
if (delay > 0f)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(delay), token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (token.IsCancellationRequested)
|
||
return;
|
||
|
||
attempt++;
|
||
_reconnectAttempt = attempt;
|
||
SetReconnectHint("Переподключение, попытка " + attempt + "…");
|
||
|
||
try
|
||
{
|
||
var ticket = await _meta.CreateTicketAsync(_info.MatchId);
|
||
_info.Ticket = ticket.Ticket;
|
||
_info.WsUrl = ticket.WsUrl;
|
||
|
||
if (await ConnectAsync(token))
|
||
{
|
||
SetReconnectHint("Соединение восстановлено. Загружаем поле…");
|
||
return;
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
return;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogException(exception);
|
||
SetReconnectHint("Попытка " + attempt + " не удалась. Повторяем…");
|
||
}
|
||
}
|
||
}
|
||
|
||
private void FinishReconnect()
|
||
{
|
||
if (!_reconnecting)
|
||
return;
|
||
|
||
_reconnecting = false;
|
||
_reconnectAttempt = 0;
|
||
if (_reconnectOverlay != null)
|
||
_reconnectOverlay.gameObject.SetActive(false);
|
||
_inputLock.Release("reconnect");
|
||
SetStatus("Соединение восстановлено.", Muted);
|
||
}
|
||
|
||
private void SetReconnectHint(string text)
|
||
{
|
||
if (_reconnectOverlayHint != null)
|
||
_reconnectOverlayHint.text = text ?? string.Empty;
|
||
}
|
||
|
||
private void SetReconnectReason(string reason)
|
||
{
|
||
if (_reconnectOverlayText == null)
|
||
return;
|
||
|
||
var value = reason;
|
||
const string prefix = "Соединение потеряно: ";
|
||
while (!string.IsNullOrEmpty(value)
|
||
&& value.StartsWith(prefix, StringComparison.Ordinal))
|
||
{
|
||
value = value.Substring(prefix.Length);
|
||
}
|
||
|
||
_reconnectOverlayText.text = string.IsNullOrWhiteSpace(value)
|
||
? "Соединение с сервером прервано. Переподключаемся автоматически."
|
||
: value;
|
||
}
|
||
|
||
private void CancelReconnect()
|
||
{
|
||
_reconnecting = false;
|
||
_reconnectAttempt = 0;
|
||
_reconnectCts?.Cancel();
|
||
_reconnectCts?.Dispose();
|
||
_reconnectCts = null;
|
||
if (_reconnectOverlay != null)
|
||
_reconnectOverlay.gameObject.SetActive(false);
|
||
_inputLock.Release("reconnect");
|
||
}
|
||
|
||
private async Task RequestSnapshotAsync()
|
||
{
|
||
if (_snapshotPending || _connection == null || !_connection.IsConnected)
|
||
return;
|
||
|
||
_snapshotPending = true;
|
||
try
|
||
{
|
||
await _connection.RequestSnapshotAsync(_cts != null ? _cts.Token : CancellationToken.None);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// экран закрыли
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogException(exception);
|
||
SetStatus("Не удалось обновить состояние.", new Color(1f, 0.60f, 0.45f));
|
||
}
|
||
finally
|
||
{
|
||
_snapshotPending = false;
|
||
}
|
||
}
|
||
|
||
private async void OnIntentClicked(IntentPayload intent)
|
||
{
|
||
if (_sending || _connection == null || _cts == null || _view == null)
|
||
return;
|
||
|
||
if (_inputLock.IsLocked && intent is not ConcedeIntent)
|
||
return;
|
||
|
||
if (_discardChoiceActive)
|
||
return;
|
||
|
||
if (_abilityDamageIndex >= 0)
|
||
{
|
||
if (intent is ActivateAbilityIntent activate)
|
||
activate.DamageIndex = _abilityDamageIndex;
|
||
else if (intent is ResolveAbilityIntent resolve)
|
||
resolve.DamageIndex = _abilityDamageIndex;
|
||
}
|
||
|
||
_abilityDamageIndex = -1;
|
||
_pendingIntents.Clear();
|
||
_highlightSlots.Clear();
|
||
_pendingBoosterSlot = -1;
|
||
_triggerPowerSlot = null;
|
||
if (intent is FinishMulliganIntent or SkipRideIntent
|
||
or EnterBattleIntent or EndTurnIntent)
|
||
{
|
||
_autoFinishMulligan = false;
|
||
_autoFinishMulliganAt = 0f;
|
||
_autoEndBattleArmed = false;
|
||
}
|
||
_sending = true;
|
||
SetStatus("Отправляем: " + Label(intent), Muted);
|
||
|
||
try
|
||
{
|
||
var result = await _connection.SubmitAsync(intent, _cts.Token);
|
||
|
||
if (!result.Accepted)
|
||
{
|
||
SetStatus(
|
||
"Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"),
|
||
new Color(1f, 0.55f, 0.45f));
|
||
if (_view != null)
|
||
Render();
|
||
}
|
||
else
|
||
{
|
||
SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted);
|
||
await RequestSnapshotAsync();
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// экран закрыли
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Debug.LogException(exception);
|
||
SetStatus("Ошибка отправки интента: " + exception.Message, new Color(1f, 0.55f, 0.45f));
|
||
}
|
||
finally
|
||
{
|
||
_sending = false;
|
||
}
|
||
}
|
||
|
||
private void ToggleIntentPanel()
|
||
{
|
||
_intentPanelVisible = !_intentPanelVisible;
|
||
if (_intentPanel != null)
|
||
_intentPanel.gameObject.SetActive(_intentPanelVisible);
|
||
if (_intentPanelToggleLabel != null)
|
||
{
|
||
_intentPanelToggleLabel.text = _intentPanelVisible
|
||
? "Скрыть панель"
|
||
: "Показать панель";
|
||
UiKit.MakeLiteral(_intentPanelToggleLabel);
|
||
}
|
||
|
||
ApplyIntentPanelLayout();
|
||
}
|
||
|
||
private void BuildReconnectOverlay()
|
||
{
|
||
_reconnectOverlay = UiKit.CreatePanel(
|
||
_root,
|
||
"ReconnectOverlay",
|
||
new Color(0f, 0f, 0f, 0.86f));
|
||
var card = UiKit.CreateCenteredCard(
|
||
_reconnectOverlay,
|
||
"ReconnectCard",
|
||
new Vector2(680f, 280f),
|
||
Panel);
|
||
UiKit.AddVerticalLayout(card, 22, 10);
|
||
|
||
var title = UiKit.CreateText(
|
||
card,
|
||
"ReconnectTitle",
|
||
"Соединение потеряно",
|
||
26,
|
||
new Color(1f, 0.78f, 0.42f),
|
||
TextAnchor.MiddleCenter);
|
||
title.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(title, 42f);
|
||
|
||
_reconnectOverlayHint = UiKit.CreateText(
|
||
card,
|
||
"ReconnectHint",
|
||
"Переподключаемся…",
|
||
18,
|
||
Color.white,
|
||
TextAnchor.MiddleCenter);
|
||
UiKit.SetPreferredHeight(_reconnectOverlayHint, 32f);
|
||
|
||
_reconnectOverlayText = UiKit.CreateText(
|
||
card,
|
||
"ReconnectReason",
|
||
string.Empty,
|
||
14,
|
||
new Color(0.72f, 0.80f, 0.94f),
|
||
TextAnchor.MiddleCenter);
|
||
_reconnectOverlayText.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
UiKit.SetPreferredHeight(_reconnectOverlayText, 64f);
|
||
|
||
var exit = UiKit.CreateButton(
|
||
card,
|
||
"ReconnectExit",
|
||
"Выйти в меню",
|
||
OnExitClicked,
|
||
17);
|
||
UiKit.SetPreferredHeight(exit, 44f);
|
||
|
||
_reconnectOverlay.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void ApplyIntentPanelLayout()
|
||
{
|
||
var shift = _intentPanelVisible ? 0f : IntentPanelShift;
|
||
if (_phasePanel != null)
|
||
_phasePanel.anchoredPosition = new Vector2(-468f + shift, 0f);
|
||
if (_opponentZones?.Root != null)
|
||
_opponentZones.Root.anchoredPosition = new Vector2(-488f + shift, -108f);
|
||
}
|
||
|
||
private void OnConcedeClicked()
|
||
{
|
||
if (_view == null || _finished)
|
||
return;
|
||
|
||
if (!_confirmConcede)
|
||
{
|
||
_confirmConcede = true;
|
||
_confirmDeadline = Time.unscaledTime + 4f;
|
||
_concedeLabel.text = "Подтвердить сдачу";
|
||
SetStatus("Нажмите «Подтвердить сдачу» ещё раз.", new Color(1f, 0.76f, 0.38f));
|
||
return;
|
||
}
|
||
|
||
_confirmConcede = false;
|
||
_concedeLabel.text = "Сдаться";
|
||
OnIntentClicked(new ConcedeIntent());
|
||
}
|
||
|
||
private void OnReconnectClicked()
|
||
{
|
||
if (_info == null)
|
||
return;
|
||
|
||
BeginReconnect("Переподключение…");
|
||
}
|
||
|
||
private void OnExitClicked()
|
||
{
|
||
Hide();
|
||
_onExit();
|
||
}
|
||
|
||
private void Render()
|
||
{
|
||
if (_view == null)
|
||
return;
|
||
|
||
_cardMenu?.Hide();
|
||
_board3D?.SetSelfPlayer(_view.You);
|
||
_board3D?.Render(_view, BuildBoardRenderState());
|
||
UpdateCombatStats();
|
||
UpdateZoneStrips();
|
||
|
||
RebuildIntents(_view);
|
||
RebuildPhaseControls(_view);
|
||
UpdateClock();
|
||
|
||
if (_view.IsFinished)
|
||
ShowResult(_view);
|
||
}
|
||
|
||
private BoardRenderState BuildBoardRenderState()
|
||
{
|
||
var state = new BoardRenderState
|
||
{
|
||
MulliganSelecting = IsMulliganSelecting(),
|
||
DamageChoice = IsDamageChoice(),
|
||
AbilityDamageTargeting = IsAbilityDamageTargeting(),
|
||
AbilityDamageIndex = _abilityDamageIndex,
|
||
MatchFinished = _view != null && _view.IsFinished,
|
||
};
|
||
|
||
foreach (var key in _highlightSlots)
|
||
state.Highlights.Add(key);
|
||
|
||
if (_view != null)
|
||
{
|
||
foreach (var intent in _view.LegalIntents ?? new List<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));
|
||
break;
|
||
case GuardIntent _:
|
||
case InterceptIntent _:
|
||
state.GuardianDropAllowed = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (state.MulliganSelecting && _view != null && _mulliganSelected.Count > 0)
|
||
{
|
||
var hand = _view.Players
|
||
.FirstOrDefault(player => player.Index == _view.You)
|
||
?.Hand;
|
||
if (hand != null)
|
||
{
|
||
foreach (var index in _mulliganSelected)
|
||
{
|
||
if (index >= 0 && index < hand.Count)
|
||
state.SelectedHandInstanceIds.Add(hand[index].InstanceId);
|
||
}
|
||
}
|
||
}
|
||
|
||
return state;
|
||
}
|
||
|
||
private BoardArt ResolveBoardArt(string printingId)
|
||
{
|
||
_catalog.TryGetValue(printingId, out var meta);
|
||
var clan = meta != null ? meta.Clan : "None";
|
||
var nameId = meta != null ? meta.NameId : printingId;
|
||
var sprite = CardImageService.Resolve(meta != null ? meta.ImageKey : null, null);
|
||
if (sprite == null)
|
||
{
|
||
sprite = CardImageService.Resolve(
|
||
CardImageService.ImageKeyForPrinting(printingId),
|
||
null);
|
||
}
|
||
|
||
if (sprite == null)
|
||
sprite = CardArt.ForCard(nameId ?? printingId ?? "card", clan);
|
||
return new BoardArt(
|
||
sprite != null ? sprite.texture : null,
|
||
CardArt.ClanColor(clan),
|
||
meta != null ? Loc.CardName(meta) : printingId);
|
||
}
|
||
|
||
private bool IsDamageChoice() =>
|
||
_view?.Choice != null
|
||
&& _view.Choice.Prompt != null
|
||
&& _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0;
|
||
|
||
private bool IsAbilityDamageTargeting()
|
||
{
|
||
if (_view == null || _view.Choice != null || _view.ActivePlayer != _view.You)
|
||
return false;
|
||
|
||
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
|
||
if (self == null || self.FaceUpDamage.Count == 0)
|
||
return false;
|
||
|
||
return (_view.LegalIntents ?? new List<IntentPayload>())
|
||
.Any(intent => intent is ActivateAbilityIntent or ResolveAbilityIntent);
|
||
}
|
||
|
||
private void OnBoard3DCardClicked(BoardCardRef card)
|
||
{
|
||
if (_view == null || _sending || _inputLock.IsLocked)
|
||
return;
|
||
|
||
if (_discardChoiceActive)
|
||
{
|
||
HandleDiscardCardClicked(card);
|
||
return;
|
||
}
|
||
|
||
var targetSelection = _pendingIntents.Count > 0
|
||
|| IsMulliganSelecting()
|
||
|| IsDamageChoice()
|
||
|| IsAbilityDamageTargeting()
|
||
|| _view.Trigger != null
|
||
|| _view.Choice != null
|
||
|| _view.Ability != null
|
||
|| _view.Order != null;
|
||
|
||
switch (card.Zone)
|
||
{
|
||
case BoardZone.Hand:
|
||
if (card.Player != _view.You)
|
||
{
|
||
OpenZone(BoardZone.Hand, card.Player);
|
||
break;
|
||
}
|
||
|
||
OnBoardTargetClicked(new BoardTarget(
|
||
card.Player, -2, card.PrintingId, card.InstanceId, true));
|
||
break;
|
||
|
||
case BoardZone.Vanguard:
|
||
case BoardZone.RearGuard:
|
||
if (targetSelection)
|
||
{
|
||
OnBoardTargetClicked(new BoardTarget(
|
||
card.Player,
|
||
card.Zone == BoardZone.Vanguard ? -1 : card.Slot,
|
||
card.PrintingId,
|
||
card.InstanceId,
|
||
false));
|
||
}
|
||
else if (card.Player == _view.You)
|
||
{
|
||
OpenCardMenu(card);
|
||
}
|
||
else
|
||
{
|
||
OpenCardDetails(card);
|
||
}
|
||
break;
|
||
|
||
case BoardZone.Damage:
|
||
OnDamageClicked(card.Index);
|
||
break;
|
||
|
||
case BoardZone.Soul:
|
||
SetStatus("Душа открывается правой кнопкой мыши.", Muted);
|
||
break;
|
||
|
||
case BoardZone.Guardian:
|
||
case BoardZone.Trigger:
|
||
OpenCardDetails(card);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void OpenCardMenu(BoardCardRef card)
|
||
{ if (_view == null || _board3D == null)
|
||
return;
|
||
|
||
var target = new BoardTarget(
|
||
card.Player,
|
||
card.Zone == BoardZone.Vanguard ? -1 : card.Slot,
|
||
card.PrintingId,
|
||
card.InstanceId,
|
||
false);
|
||
|
||
var legal = _view.LegalIntents ?? new List<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)
|
||
{
|
||
actions.Add(new CardActionMenuItem("Переместить", () =>
|
||
{
|
||
BeginTargetSelection(moves, "Выберите круг для перемещения.");
|
||
}));
|
||
}
|
||
|
||
var abilities = sources.OfType<ActivateAbilityIntent>().ToList();
|
||
if (abilities.Count > 0)
|
||
{
|
||
actions.Add(new CardActionMenuItem(
|
||
"Активировать способность",
|
||
() => OpenAbilitySelector(abilities)));
|
||
}
|
||
|
||
foreach (var intent in sources)
|
||
{
|
||
if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent)
|
||
continue;
|
||
|
||
var captured = intent;
|
||
actions.Add(new CardActionMenuItem(
|
||
Label(captured),
|
||
() => OnIntentClicked(captured)));
|
||
}
|
||
|
||
actions.Add(new CardActionMenuItem("Подробнее", () => OpenCardDetails(card)));
|
||
|
||
if (!_board3D.TryGetCardScreenPosition(card, out var screen))
|
||
return;
|
||
|
||
_inputLock.Acquire("card-menu");
|
||
_cardMenu.Show(
|
||
screen,
|
||
CardName(card.PrintingId),
|
||
actions,
|
||
() => _inputLock.Release("card-menu"));
|
||
}
|
||
|
||
private void BeginTargetSelection(
|
||
IEnumerable<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 || _inputLock.IsLocked || _discardChoiceActive)
|
||
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)
|
||
{
|
||
OnIntentClicked(free ?? distinct[0]);
|
||
return;
|
||
}
|
||
|
||
if (free != null)
|
||
{
|
||
ShowChoiceMenu(
|
||
"Способность",
|
||
new List<CardActionMenuItem>
|
||
{
|
||
new CardActionMenuItem(
|
||
"Без сброса",
|
||
() => OnIntentClicked(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)
|
||
{
|
||
FinishDiscardSelection();
|
||
OnIntentClicked(ability);
|
||
return;
|
||
}
|
||
|
||
var guard = _discardGuardChoices.FirstOrDefault(
|
||
item => item.DiscardPrintingId == printing);
|
||
if (guard != null)
|
||
{
|
||
FinishDiscardSelection();
|
||
OnIntentClicked(guard);
|
||
return;
|
||
}
|
||
|
||
SetStatus("Эта карта не подходит для сброса.", new Color(1f, 0.55f, 0.45f));
|
||
}
|
||
|
||
private void StartDiscardSelection(
|
||
string prompt,
|
||
IReadOnlyList<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;
|
||
}
|
||
|
||
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)
|
||
return;
|
||
|
||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||
IntentPayload intent = null;
|
||
|
||
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)
|
||
{
|
||
ShowGuardDiscardChoice(guards, Render);
|
||
return;
|
||
}
|
||
|
||
intent = guards.FirstOrDefault();
|
||
}
|
||
else if (target.Player == _view.You && target.Zone == BoardZone.Vanguard)
|
||
{
|
||
intent = legal.OfType<RideIntent>()
|
||
.FirstOrDefault(item => item.PrintingId == 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 && 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)
|
||
{
|
||
SetStatus("Сюда нельзя положить эту карту.", new Color(1f, 0.55f, 0.45f));
|
||
RefreshBoardHighlights();
|
||
return;
|
||
}
|
||
|
||
ClearSelection(rebuild: false);
|
||
OnIntentClicked(intent);
|
||
}
|
||
|
||
private void OnBoard3DPileRightClicked(BoardCardRef pile)
|
||
{
|
||
if (_view == null || _inputLock.IsLocked)
|
||
return;
|
||
|
||
if (pile.Zone == BoardZone.Soul)
|
||
OpenZone(BoardZone.Soul, pile.Player);
|
||
}
|
||
|
||
private void OnBoard3DCardRightClicked(BoardCardRef card)
|
||
{
|
||
if (_view == null || _inputLock.IsLocked)
|
||
return;
|
||
|
||
if (card.Zone == BoardZone.Vanguard)
|
||
OpenZone(BoardZone.Soul, card.Player);
|
||
}
|
||
|
||
private void OnBoard3DPileClicked(BoardCardRef pile)
|
||
{
|
||
if (_view == null || _inputLock.IsLocked)
|
||
return;
|
||
|
||
if (pile.Zone == BoardZone.Soul)
|
||
{
|
||
SetStatus("Душа открывается правой кнопкой мыши по VC.", Muted);
|
||
return;
|
||
}
|
||
|
||
if (pile.Zone == BoardZone.Drop)
|
||
{
|
||
OpenZone(BoardZone.Drop, pile.Player);
|
||
return;
|
||
}
|
||
|
||
if (pile.Zone == BoardZone.Deck)
|
||
OpenZone(BoardZone.Deck, pile.Player);
|
||
}
|
||
|
||
private void BuildPhasePanel()
|
||
{
|
||
_phasePanel = UiKit.CreatePanel(
|
||
_root,
|
||
"PhasePanel",
|
||
new Color(0.02f, 0.035f, 0.055f, 0.84f));
|
||
_phasePanel.anchorMin = new Vector2(1f, 0.5f);
|
||
_phasePanel.anchorMax = new Vector2(1f, 0.5f);
|
||
_phasePanel.pivot = new Vector2(1f, 0.5f);
|
||
_phasePanel.anchoredPosition = new Vector2(-468f, 0f);
|
||
_phasePanel.sizeDelta = new Vector2(156f, 188f);
|
||
UiKit.AddVerticalLayout(_phasePanel, 8, 6);
|
||
_phasePanel.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void RebuildPhaseControls(MatchView view)
|
||
{
|
||
if (_phasePanel == null)
|
||
return;
|
||
|
||
ClearChildren(_phasePanel);
|
||
if (view == null || _pendingIntents.Count > 0)
|
||
{
|
||
_phasePanel.gameObject.SetActive(false);
|
||
return;
|
||
}
|
||
|
||
var intents = (view.LegalIntents ?? new List<IntentPayload>())
|
||
.Where(IsPhaseIntent)
|
||
.Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1))
|
||
.ToList();
|
||
if (intents.Count == 0)
|
||
{
|
||
_phasePanel.gameObject.SetActive(false);
|
||
return;
|
||
}
|
||
|
||
_phasePanel.gameObject.SetActive(true);
|
||
var title = UiKit.CreateText(
|
||
_phasePanel,
|
||
"PhaseTitle",
|
||
"ФАЗЫ",
|
||
14,
|
||
new Color(0.72f, 0.80f, 0.94f));
|
||
title.fontStyle = FontStyle.Bold;
|
||
UiKit.SetPreferredHeight(title, 22f);
|
||
|
||
foreach (var ride in (view.LegalIntents ?? new List<IntentPayload>())
|
||
.OfType<RideIntent>()
|
||
.GroupBy(item => item.PrintingId)
|
||
.Select(group => group.First()))
|
||
{
|
||
var captured = ride;
|
||
var rideButton = UiKit.CreateButton(
|
||
_phasePanel,
|
||
"Phase_Ride_" + captured.PrintingId,
|
||
"Сменить: " + CardName(captured.PrintingId),
|
||
() => OnIntentClicked(captured),
|
||
14);
|
||
rideButton.interactable = !_inputLock.IsLocked;
|
||
UiKit.SetPreferredHeight(rideButton, 40f);
|
||
}
|
||
|
||
foreach (var intent in intents)
|
||
{
|
||
var captured = intent;
|
||
if (captured is FinishMulliganIntent && _mulliganSelected.Count > 0)
|
||
{
|
||
var exchange = UiKit.CreateButton(
|
||
_phasePanel,
|
||
"Phase_MulliganExchange",
|
||
"Заменить " + _mulliganSelected.Count + " карт",
|
||
SendMulligan,
|
||
14);
|
||
exchange.interactable = !_inputLock.IsLocked;
|
||
UiKit.SetPreferredHeight(exchange, 40f);
|
||
continue;
|
||
}
|
||
|
||
var button = UiKit.CreateButton(
|
||
_phasePanel,
|
||
"Phase_" + captured.GetType().Name,
|
||
PhaseLabel(captured),
|
||
() => OnIntentClicked(captured),
|
||
14);
|
||
button.interactable = !_inputLock.IsLocked;
|
||
UiKit.SetPreferredHeight(button, 40f);
|
||
}
|
||
}
|
||
|
||
private static bool IsPhaseIntent(IntentPayload intent) =>
|
||
intent is FinishMulliganIntent or SkipRideIntent
|
||
or EnterBattleIntent or EndTurnIntent;
|
||
|
||
private static string PhaseLabel(IntentPayload intent) => intent switch
|
||
{
|
||
FinishMulliganIntent => "Закончить муллиган",
|
||
SkipRideIntent => "Пропустить смену",
|
||
EnterBattleIntent => "В бой",
|
||
EndTurnIntent => "Закончить ход",
|
||
_ => intent.GetType().Name,
|
||
};
|
||
|
||
private void BuildOverlaysAndZones()
|
||
{
|
||
_overlays = new OverlayNavigationService(_root, ResolveCatalogItem);
|
||
_overlays.OpenChanged += OnOverlayOpenChanged;
|
||
|
||
_selfZones = new ZoneBlockStripView();
|
||
_selfZones.Build(
|
||
_root,
|
||
"ZoneBlocksSelf",
|
||
new Vector2(0f, 0f),
|
||
new Vector2(0f, 0f),
|
||
new Vector2(30f, 24f),
|
||
new Vector2(360f, 108f));
|
||
_selfZones.ZoneClicked += zone => OpenZone(zone, _view?.You ?? 0);
|
||
|
||
_opponentZones = new ZoneBlockStripView();
|
||
_opponentZones.Build(
|
||
_root,
|
||
"ZoneBlocksOpponent",
|
||
new Vector2(1f, 1f),
|
||
new Vector2(1f, 1f),
|
||
new Vector2(-488f, -108f),
|
||
new Vector2(360f, 108f));
|
||
_opponentZones.ZoneClicked += zone =>
|
||
OpenZone(zone, _view != null ? 1 - _view.You : 1);
|
||
}
|
||
|
||
private void BuildCombatStats()
|
||
{
|
||
_combatStats = UiKit.CreatePanel(
|
||
_root,
|
||
"CombatStats",
|
||
new Color(0.03f, 0.045f, 0.07f, 0.9f));
|
||
_combatStats.anchorMin = new Vector2(0.5f, 1f);
|
||
_combatStats.anchorMax = new Vector2(0.5f, 1f);
|
||
_combatStats.pivot = new Vector2(0.5f, 1f);
|
||
_combatStats.anchoredPosition = new Vector2(0f, -18f);
|
||
_combatStats.sizeDelta = new Vector2(620f, 48f);
|
||
var statsImage = _combatStats.GetComponent<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;
|
||
var text = battle.Nullified
|
||
? "POWER " + attackPower + " · NULLIFIED"
|
||
: "POWER " + attackPower + " vs SHIELD " + shield;
|
||
_combatStatsText.text = text;
|
||
_combatStatsText.color = battle.Nullified
|
||
? new Color(0.7f, 0.76f, 0.9f)
|
||
: attackPower >= shield
|
||
? new Color(1f, 0.72f, 0.32f)
|
||
: new Color(0.52f, 0.82f, 1f);
|
||
_combatStats.gameObject.SetActive(true);
|
||
}
|
||
|
||
private CardView BattleCard(int playerIndex, int slot)
|
||
{
|
||
var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex);
|
||
if (player == null)
|
||
return null;
|
||
|
||
if (slot < 0)
|
||
return player.Vanguard;
|
||
|
||
return player.RearGuards != null
|
||
&& slot < player.RearGuards.Length
|
||
? player.RearGuards[slot]
|
||
: null;
|
||
}
|
||
|
||
private void UpdateZoneStrips()
|
||
{
|
||
if (_view == null)
|
||
return;
|
||
|
||
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
|
||
var opponent = _view.Players.FirstOrDefault(player => player.Index != _view.You);
|
||
_selfZones?.Update(self, self: true);
|
||
_opponentZones?.Update(opponent, self: false);
|
||
}
|
||
|
||
private void OpenZone(BoardZone zone, int playerIndex)
|
||
{
|
||
if (_view == null)
|
||
return;
|
||
|
||
var player = _view.Players.FirstOrDefault(item => item.Index == playerIndex);
|
||
if (player == null)
|
||
return;
|
||
|
||
_overlays?.OpenZone(player, _view.You, zone);
|
||
}
|
||
|
||
private CardCatalogItemDto ResolveCatalogItem(string printingId) =>
|
||
_catalog.TryGetValue(printingId, out var item) ? item : null;
|
||
private void OnDamageClicked(int displayIndex)
|
||
{
|
||
if (_inputLock.IsLocked)
|
||
return;
|
||
|
||
if (IsDamageChoice())
|
||
{
|
||
if (_view?.Choice?.Options == null)
|
||
return;
|
||
|
||
for (var i = 0; i < _view.Choice.Options.Count; i++)
|
||
{
|
||
if (_view.Choice.Options[i].Slot == displayIndex)
|
||
{
|
||
OnIntentClicked(new ChooseIntent { Index = i });
|
||
return;
|
||
}
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (!IsAbilityDamageTargeting())
|
||
return;
|
||
|
||
_abilityDamageIndex = displayIndex;
|
||
SetStatus(
|
||
"Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".",
|
||
new Color(0.45f, 0.95f, 0.62f));
|
||
Render();
|
||
}
|
||
|
||
private string CardName(string printingId) =>
|
||
_catalog.TryGetValue(printingId, out var meta) ? Loc.CardName(meta) : printingId;
|
||
|
||
private static string TriggerShort(string trigger) => trigger switch
|
||
{
|
||
"Critical" => "K",
|
||
"Draw" => "D",
|
||
"Stand" => "S",
|
||
"Heal" => "H",
|
||
_ => "—",
|
||
};
|
||
|
||
private bool IsMulliganSelecting() =>
|
||
_view != null
|
||
&& !_view.IsFinished
|
||
&& _view.Phase == GamePhase.Mulligan
|
||
&& _view.ActivePlayer == _view.You
|
||
&& !_mulliganUsed;
|
||
|
||
private int HandIndexOf(int instanceId)
|
||
{
|
||
var hand = _view?.Players.FirstOrDefault(player => player.Index == _view.You)?.Hand;
|
||
if (hand == null)
|
||
return -1;
|
||
|
||
for (var i = 0; i < hand.Count; i++)
|
||
{
|
||
if (hand[i].InstanceId == instanceId)
|
||
return i;
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
private void ToggleMulligan(int index)
|
||
{
|
||
if (!_mulliganSelected.Add(index))
|
||
_mulliganSelected.Remove(index);
|
||
|
||
SetStatus(
|
||
_mulliganSelected.Count == 0
|
||
? "Кликните карты, которые вернутся в колоду."
|
||
: "К обмену выбрано карт: " + _mulliganSelected.Count + ".",
|
||
new Color(0.98f, 0.82f, 0.42f));
|
||
Render();
|
||
}
|
||
|
||
private void SendMulligan()
|
||
{
|
||
if (!IsMulliganSelecting() || _mulliganSelected.Count == 0
|
||
|| _inputLock.IsLocked)
|
||
return;
|
||
|
||
var indexes = _mulliganSelected.OrderBy(index => index).ToList();
|
||
_mulliganSelected.Clear();
|
||
_mulliganUsed = true;
|
||
_autoFinishMulligan = true;
|
||
_autoFinishMulliganAt = 0f;
|
||
OnIntentClicked(new MulliganIntent { HandIndexes = indexes });
|
||
}
|
||
|
||
private void OnBoardTargetClicked(BoardTarget target)
|
||
{
|
||
if (_view == null || _sending || _inputLock.IsLocked)
|
||
return;
|
||
|
||
if (IsMulliganSelecting() && target.IsHand && target.Player == _view.You)
|
||
{
|
||
var handIndex = HandIndexOf(target.InstanceId);
|
||
if (handIndex >= 0)
|
||
{
|
||
ToggleMulligan(handIndex);
|
||
return;
|
||
}
|
||
}
|
||
|
||
var legal = _view.LegalIntents ?? new List<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);
|
||
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);
|
||
OnIntentClicked(sources[0]);
|
||
return;
|
||
}
|
||
|
||
_pendingIntents.Clear();
|
||
_pendingIntents.AddRange(withTargets);
|
||
RebuildHighlights();
|
||
RebuildIntents(_view);
|
||
SetStatus("Выберите цель на поле.", new Color(0.98f, 0.82f, 0.42f));
|
||
}
|
||
|
||
private void ClearSelection(bool rebuild = true)
|
||
{
|
||
_pendingIntents.Clear();
|
||
_highlightSlots.Clear();
|
||
_pendingBoosterSlot = -1;
|
||
_triggerPowerSlot = null;
|
||
if (rebuild && _view != null)
|
||
RebuildIntents(_view);
|
||
RefreshBoardHighlights();
|
||
}
|
||
|
||
private void PrepareTriggerSelection()
|
||
{
|
||
if (_view?.Trigger == null || _view.ActivePlayer != _view.You)
|
||
return;
|
||
|
||
var triggers = (_view.LegalIntents ?? new List<IntentPayload>())
|
||
.OfType<ResolveTriggerIntent>()
|
||
.ToList();
|
||
if (triggers.Count == 0)
|
||
return;
|
||
|
||
_pendingIntents.Clear();
|
||
_pendingIntents.AddRange(triggers);
|
||
_triggerPowerSlot = null;
|
||
RebuildHighlights();
|
||
}
|
||
|
||
private bool HandleTriggerTarget(BoardTarget target)
|
||
{
|
||
if (_view?.Trigger == null || target.Player != _view.You)
|
||
return false;
|
||
|
||
var triggers = _pendingIntents.OfType<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);
|
||
RebuildHighlights();
|
||
RebuildIntents(_view);
|
||
SetStatus(
|
||
"Триггер: выберите цель эффекта.",
|
||
new Color(0.98f, 0.82f, 0.42f));
|
||
return true;
|
||
}
|
||
|
||
var effect = triggers.FirstOrDefault(trigger => trigger.EffectTarget == target.Slot);
|
||
if (effect == null)
|
||
return false;
|
||
|
||
ClearSelection(rebuild: false);
|
||
OnIntentClicked(effect);
|
||
return true;
|
||
}
|
||
|
||
private void RebuildHighlights()
|
||
{
|
||
_highlightSlots.Clear();
|
||
|
||
if (_view == null)
|
||
return;
|
||
|
||
if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent))
|
||
{
|
||
foreach (var trigger in _pendingIntents.OfType<ResolveTriggerIntent>())
|
||
{
|
||
var slot = _triggerPowerSlot == null
|
||
? trigger.PowerTarget
|
||
: trigger.EffectTarget;
|
||
_highlightSlots.Add(new BoardSlotKey(_view.You, slot));
|
||
}
|
||
|
||
RefreshBoardHighlights();
|
||
return;
|
||
}
|
||
|
||
foreach (var intent in _pendingIntents)
|
||
{
|
||
if (TryGetTarget(intent, out var target) && target.Slot >= -1)
|
||
_highlightSlots.Add(new BoardSlotKey(target.Player, target.Slot));
|
||
|
||
if (intent is AttackIntent attack && attack.BoosterSlot >= 0)
|
||
_highlightSlots.Add(new BoardSlotKey(_view.You, attack.BoosterSlot));
|
||
}
|
||
|
||
RefreshBoardHighlights();
|
||
}
|
||
|
||
private void RefreshBoardHighlights()
|
||
{
|
||
if (_view != null && _board3D != null)
|
||
_board3D.Render(_view, BuildBoardRenderState());
|
||
}
|
||
|
||
private bool TryGetSource(IntentPayload intent, out BoardTarget source)
|
||
{
|
||
source = default;
|
||
if (_view == null)
|
||
return false;
|
||
|
||
switch (intent)
|
||
{
|
||
case RideIntent ride:
|
||
source = new BoardTarget(_view.You, -2, ride.PrintingId, 0, true);
|
||
return true;
|
||
case CallIntent call:
|
||
source = new BoardTarget(_view.You, -2, call.PrintingId, 0, true);
|
||
return true;
|
||
case GuardIntent guard:
|
||
source = new BoardTarget(_view.You, -2, guard.PrintingId, 0, true);
|
||
return true;
|
||
case MoveIntent move:
|
||
source = FieldTarget(_view.You, move.FromSlot);
|
||
return true;
|
||
case AttackIntent attack:
|
||
source = FieldTarget(_view.You, attack.AttackerSlot);
|
||
return true;
|
||
case InterceptIntent intercept:
|
||
source = FieldTarget(_view.You, intercept.Slot);
|
||
return true;
|
||
case ActivateAbilityIntent activate:
|
||
source = InstanceTarget(_view.You, activate.InstanceId);
|
||
return source.InstanceId != 0;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private bool TryGetTarget(IntentPayload intent, out BoardTarget target)
|
||
{
|
||
target = default;
|
||
if (_view == null)
|
||
return false;
|
||
|
||
switch (intent)
|
||
{
|
||
case CallIntent call:
|
||
target = FieldTarget(_view.You, call.Slot);
|
||
return true;
|
||
case MoveIntent move:
|
||
target = FieldTarget(_view.You, move.ToSlot);
|
||
return true;
|
||
case AttackIntent attack:
|
||
target = FieldTarget(1 - _view.You, attack.TargetSlot);
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private BoardTarget FieldTarget(int player, int slot)
|
||
{
|
||
var card = CardAt(player, slot);
|
||
return new BoardTarget(player, slot, card != null ? card.PrintingId : null,
|
||
card != null ? card.InstanceId : 0, false);
|
||
}
|
||
|
||
private BoardTarget InstanceTarget(int player, int instanceId)
|
||
{
|
||
var card = InstanceAt(player, instanceId);
|
||
if (card == null)
|
||
return default;
|
||
|
||
if (_view != null
|
||
&& _view.Players.FirstOrDefault(item => item.Index == player)?.Vanguard is { } vanguard
|
||
&& vanguard.InstanceId == instanceId)
|
||
{
|
||
return new BoardTarget(player, -1, card.PrintingId, instanceId, false);
|
||
}
|
||
|
||
var field = _view?.Players.FirstOrDefault(item => item.Index == player);
|
||
if (field?.RearGuards != null)
|
||
{
|
||
for (var i = 0; i < field.RearGuards.Length; i++)
|
||
{
|
||
if (field.RearGuards[i]?.InstanceId == instanceId)
|
||
return new BoardTarget(player, i, card.PrintingId, instanceId, false);
|
||
}
|
||
}
|
||
|
||
return default;
|
||
}
|
||
|
||
private CardView CardAt(int player, int slot)
|
||
{
|
||
var side = _view?.Players.FirstOrDefault(item => item.Index == player);
|
||
if (side == null)
|
||
return null;
|
||
|
||
if (slot == -1)
|
||
return side.Vanguard;
|
||
|
||
return side.RearGuards != null && slot >= 0 && slot < side.RearGuards.Length
|
||
? side.RearGuards[slot]
|
||
: null;
|
||
}
|
||
|
||
private CardView InstanceAt(int player, int instanceId)
|
||
{
|
||
var side = _view?.Players.FirstOrDefault(item => item.Index == player);
|
||
if (side == null)
|
||
return null;
|
||
|
||
if (side.Vanguard?.InstanceId == instanceId)
|
||
return side.Vanguard;
|
||
|
||
return side.RearGuards?.FirstOrDefault(card => card?.InstanceId == instanceId);
|
||
}
|
||
|
||
private bool IsBoardIntent(IntentPayload intent) =>
|
||
intent is RideIntent or CallIntent or MoveIntent or AttackIntent
|
||
or GuardIntent or InterceptIntent or ActivateAbilityIntent;
|
||
|
||
private void RebuildIntents(MatchView view)
|
||
{
|
||
ClearChildren(_intents);
|
||
|
||
if (_discardChoiceActive)
|
||
{
|
||
BuildDiscardPrompt();
|
||
return;
|
||
}
|
||
|
||
var legal = view.LegalIntents ?? new List<IntentPayload>();
|
||
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
|
||
var abilities = playable.OfType<ActivateAbilityIntent>().ToList();
|
||
var guards = playable.OfType<GuardIntent>().ToList();
|
||
var rides = playable.OfType<RideIntent>().ToList();
|
||
var board = playable
|
||
.Where(intent => IsBoardIntent(intent)
|
||
&& intent is not ActivateAbilityIntent
|
||
&& intent is not GuardIntent
|
||
&& intent is not RideIntent)
|
||
.ToList();
|
||
var global = playable
|
||
.Where(intent => !IsBoardIntent(intent)
|
||
&& !IsPhaseIntent(intent)
|
||
&& intent is not ActivateAbilityIntent)
|
||
.ToList();
|
||
|
||
if (_pendingIntents.Count > 0)
|
||
{
|
||
var prompt = UiKit.CreateText(
|
||
_intents,
|
||
"TargetPrompt",
|
||
_pendingIntents.Any(intent => intent is ResolveTriggerIntent)
|
||
? (_triggerPowerSlot == null
|
||
? "Триггер: выберите цель силы."
|
||
: "Триггер: выберите цель эффекта.")
|
||
: _pendingBoosterSlot >= 0
|
||
? "Поддержка: " + CardName(
|
||
CardAt(_view.You, _pendingBoosterSlot)?.PrintingId ?? string.Empty)
|
||
+ ".\nВыберите цель атаки или «Отмена»."
|
||
: "Выберите цель на поле.\nНажмите «Отмена», чтобы снять выбор.",
|
||
16,
|
||
new Color(0.98f, 0.82f, 0.42f),
|
||
TextAnchor.UpperLeft);
|
||
prompt.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
UiKit.SetPreferredHeight(prompt, 64f);
|
||
|
||
var cancel = UiKit.CreateButton(
|
||
_intents, "CancelTarget", "Отмена", () =>
|
||
{
|
||
ClearSelection();
|
||
Render();
|
||
}, 17);
|
||
UiKit.SetPreferredHeight(cancel, 44f);
|
||
return;
|
||
}
|
||
|
||
if (playable.Count == 0)
|
||
{
|
||
var waiting = UiKit.CreateText(
|
||
_intents,
|
||
"Waiting",
|
||
view.IsFinished ? "Матч завершён." : "Сейчас решает противник…",
|
||
18,
|
||
Muted);
|
||
UiKit.SetPreferredHeight(waiting, 110f);
|
||
return;
|
||
}
|
||
|
||
if (IsMulliganSelecting())
|
||
{
|
||
var mulliganHint = UiKit.CreateText(
|
||
_intents,
|
||
"MulliganHint",
|
||
"Кликните карты, которые вернутся в колоду.",
|
||
15,
|
||
new Color(0.72f, 0.80f, 0.94f),
|
||
TextAnchor.UpperLeft);
|
||
mulliganHint.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
UiKit.SetPreferredHeight(mulliganHint, 34f);
|
||
}
|
||
|
||
if (IsAbilityDamageTargeting())
|
||
{
|
||
var abilityHint = UiKit.CreateText(
|
||
_intents,
|
||
"AbilityDamageHint",
|
||
"Выберите закрытую карту урона для Counter Blast (необязательно).",
|
||
15,
|
||
new Color(0.72f, 0.80f, 0.94f),
|
||
TextAnchor.UpperLeft);
|
||
abilityHint.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
UiKit.SetPreferredHeight(abilityHint, 34f);
|
||
}
|
||
|
||
if (board.Count > 0)
|
||
{
|
||
var hint = UiKit.CreateText(
|
||
_intents,
|
||
"BoardHint",
|
||
"Нажмите на карту в руке или на поле.\nДопустимые цели подсветятся.",
|
||
15,
|
||
new Color(0.72f, 0.80f, 0.94f),
|
||
TextAnchor.UpperLeft);
|
||
hint.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||
UiKit.SetPreferredHeight(hint, 52f);
|
||
|
||
var toggle = UiKit.CreateButton(
|
||
_intents,
|
||
"ToggleAllIntents",
|
||
_showAllIntents ? "Скрыть все действия" : "Показать все действия",
|
||
() =>
|
||
{
|
||
_showAllIntents = !_showAllIntents;
|
||
RebuildIntents(_view);
|
||
},
|
||
15);
|
||
UiKit.SetPreferredHeight(toggle, 38f);
|
||
}
|
||
|
||
if (abilities.Count > 0)
|
||
AddAbilityButton(abilities);
|
||
|
||
if (guards.Count > 0)
|
||
AddGuardButton(guards);
|
||
|
||
if (rides.Count > 0)
|
||
AddRideButtons(rides);
|
||
|
||
foreach (var intent in global)
|
||
AddIntentButton(intent);
|
||
|
||
if (_showAllIntents)
|
||
{
|
||
foreach (var intent in board)
|
||
AddIntentButton(intent);
|
||
}
|
||
}
|
||
|
||
private void AddIntentButton(IntentPayload intent)
|
||
{
|
||
var captured = intent;
|
||
var button = UiKit.CreateButton(
|
||
_intents,
|
||
"Intent_" + captured.GetType().Name,
|
||
Label(captured),
|
||
() => OnIntentClicked(captured),
|
||
16);
|
||
button.interactable = !_inputLock.IsLocked;
|
||
UiKit.SetPreferredHeight(button, 46f);
|
||
}
|
||
|
||
private void ShowResult(MatchView view)
|
||
{
|
||
if (_result.gameObject.activeSelf)
|
||
return;
|
||
|
||
_result.SetAsLastSibling();
|
||
_result.gameObject.SetActive(true);
|
||
_concede.interactable = false;
|
||
|
||
_resultText.text = view.Winner < 0
|
||
? "Ничья или матч прерван."
|
||
: view.Winner == view.You
|
||
? "Победа!"
|
||
: "Поражение.";
|
||
|
||
_resultText.text += "\nМатч " + view.MatchId + " завершён.";
|
||
}
|
||
|
||
private void UpdateClock()
|
||
{
|
||
if (_view?.Clock == null || !_view.Clock.IsRunning)
|
||
{
|
||
_clock.text = "—";
|
||
return;
|
||
}
|
||
|
||
var localNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
var serverNow = _clockServerMs + (localNow - _clockReceivedMs);
|
||
var remaining = Math.Max(0, _view.Clock.TurnDeadlineMs - serverNow);
|
||
var bank = _view.Clock.DecisionPlayer == _view.You
|
||
? _view.Clock.Bank0Ms
|
||
: _view.Clock.Bank1Ms;
|
||
|
||
_clock.text = Format(remaining) + " · банк " + Format(bank);
|
||
}
|
||
|
||
private static string Format(long milliseconds)
|
||
{
|
||
var totalSeconds = Math.Max(0, milliseconds / 1000);
|
||
var minutes = totalSeconds / 60;
|
||
var seconds = totalSeconds % 60;
|
||
return minutes.ToString("00") + ":" + seconds.ToString("00");
|
||
}
|
||
|
||
private static void ClearChildren(Transform parent)
|
||
{
|
||
for (var i = parent.childCount - 1; i >= 0; i--)
|
||
{
|
||
var child = parent.GetChild(i).gameObject;
|
||
if (Application.isPlaying)
|
||
UnityEngine.Object.Destroy(child);
|
||
else
|
||
UnityEngine.Object.DestroyImmediate(child);
|
||
}
|
||
}
|
||
|
||
private static string SlotName(int slot)
|
||
{
|
||
if (slot < 0)
|
||
return "Vanguard";
|
||
|
||
return "R" + (slot + 1);
|
||
}
|
||
|
||
private string Label(IntentPayload intent)
|
||
{
|
||
switch (intent)
|
||
{
|
||
case FinishMulliganIntent _: return "Закончить маллиган";
|
||
case MulliganIntent mulligan: return "Маллиган: " + mulligan.HandIndexes.Count + " карт";
|
||
case SkipRideIntent _: return "Пропустить Ride";
|
||
case RideIntent ride: return "Ride: " + CardName(ride.PrintingId);
|
||
case CallIntent call: return "Call " + CardName(call.PrintingId) + " → " + SlotName(call.Slot);
|
||
case MoveIntent move: return "Move " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot);
|
||
case EnterBattleIntent _: return "В фазу боя";
|
||
case AttackIntent attack:
|
||
return "Атака " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot)
|
||
+ (attack.BoosterSlot >= 0 ? " + boost" : string.Empty);
|
||
case GuardIntent guard: return "Guard: " + CardName(guard.PrintingId);
|
||
case InterceptIntent intercept: return "Intercept: " + SlotName(intercept.Slot);
|
||
case PassGuardIntent _: return "Не защищаться";
|
||
case ResolveTriggerIntent trigger:
|
||
return "Триггер: сила " + SlotName(trigger.PowerTarget)
|
||
+ ", эффект " + SlotName(trigger.EffectTarget);
|
||
case SelectTriggerIntent select: return "Выбрать триггер #" + select.Index;
|
||
case ResolveAbilityIntent ability:
|
||
return ability.Accept ? "Применить способность" : "Пропустить способность";
|
||
case ChooseIntent choose: return ChoiceLabel(choose.Index);
|
||
case ActivateAbilityIntent activate:
|
||
return "Способность #" + activate.InstanceId;
|
||
case EndTurnIntent _: return "Закончить ход";
|
||
case ConcedeIntent _: return "Сдаться";
|
||
default: return intent.GetType().Name;
|
||
}
|
||
}
|
||
|
||
private string ChoiceLabel(int index)
|
||
{
|
||
if (_view?.Choice?.Options != null
|
||
&& index >= 0
|
||
&& index < _view.Choice.Options.Count)
|
||
{
|
||
return "Выбрать: " + _view.Choice.Options[index].Label;
|
||
}
|
||
|
||
return "Выбрать вариант #" + index;
|
||
}
|
||
|
||
private void SetStatus(string text, Color color)
|
||
{
|
||
_status.text = text ?? string.Empty;
|
||
_status.color = color;
|
||
}
|
||
|
||
private readonly struct BoardTarget
|
||
{
|
||
public BoardTarget(
|
||
int player,
|
||
int slot,
|
||
string printingId,
|
||
int instanceId,
|
||
bool isHand)
|
||
{
|
||
Player = player;
|
||
Slot = slot;
|
||
PrintingId = printingId;
|
||
InstanceId = instanceId;
|
||
IsHand = isHand;
|
||
}
|
||
|
||
public int Player { get; }
|
||
public int Slot { get; }
|
||
public string PrintingId { get; }
|
||
public int InstanceId { get; }
|
||
public bool IsHand { get; }
|
||
|
||
public bool Matches(BoardTarget other)
|
||
{
|
||
if (IsHand || other.IsHand)
|
||
{
|
||
return IsHand && other.IsHand
|
||
&& Player == other.Player
|
||
&& string.Equals(PrintingId, other.PrintingId, StringComparison.Ordinal);
|
||
}
|
||
|
||
return Player == other.Player && Slot == other.Slot;
|
||
}
|
||
}
|
||
}
|
||
}
|