VanguardReMaster/client/Assets/Vanguard/Runtime/UI/BattleView.cs

3447 lines
126 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 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 RectTransform _abilityOverlay;
private Text _abilityOverlayName;
private RectTransform _abilityOverlayButtons;
private int _abilityPromptInstanceId;
private RectTransform _choiceOverlay;
private Text _choiceOverlayTitle;
private RectTransform _choiceOverlayButtons;
private string _choicePromptKey;
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 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", 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);
BuildActionPrompt();
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();
BuildAbilityOverlay();
BuildChoiceOverlay();
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();
HideActionPrompt();
_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();
HideAbilityOverlay();
HideChoiceOverlay();
_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;
HideActionPrompt();
_root.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
_overlays?.CloseAll();
HideAbilityOverlay();
HideChoiceOverlay();
_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();
RefreshActionPrompt();
SanitizeInputLocks();
SyncAbilityPrompt();
SyncChoicePrompt();
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 (_choiceOverlay == null || !_choiceOverlay.gameObject.activeSelf)
_inputLock.Release("choice");
if (!_feedbackPlaying && _feedbackQueue.Count == 0)
{
_feedbackLockHeld = false;
_inputLock.Release("feedback");
}
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)
{
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
// After an exchange the mulligan must finish by itself;
// feedback/overlay 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 && !_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();
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();
_triggerPowerSlot = null;
PrepareTriggerSelection();
if (_view.Clock != null)
{
_clockServerMs = _view.Clock.ServerTimeMs;
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
Render();
SyncAbilityPrompt();
SyncChoicePrompt();
}
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 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 (_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)
{
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();
}
else
{
if (intent is MulliganIntent)
{
// The exchange itself completes the mulligan; finish it
// automatically even if a feedback/overlay lock is active.
_mulliganUsed = true;
_autoFinishMulligan = true;
_autoFinishMulliganAt = Time.unscaledTime + 0.3f;
if (_view != null)
RebuildPhaseControls(_view);
}
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 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 void BuildChoiceOverlay()
{
_choiceOverlay = UiKit.CreatePanel(
_root,
"ChoiceOverlay",
new Color(0f, 0f, 0f, 0.72f));
var card = UiKit.CreateCenteredCard(
_choiceOverlay,
"ChoiceCard",
new Vector2(760f, 300f),
Panel);
UiKit.AddVerticalLayout(card, 22, 10);
var title = UiKit.CreateText(
card,
"ChoiceTitle",
"Выбор",
22,
new Color(1f, 0.78f, 0.42f),
TextAnchor.MiddleCenter);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 34f);
_choiceOverlayTitle = UiKit.CreateText(
card,
"ChoicePrompt",
string.Empty,
18,
Color.white,
TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(_choiceOverlayTitle, 52f);
_choiceOverlayButtons = UiKit.CreatePanel(
card, "ChoiceButtons", Color.clear);
UiKit.AddHorizontalLayout(_choiceOverlayButtons, 0, 8);
UiKit.SetPreferredHeight(_choiceOverlayButtons, 52f);
_choiceOverlay.gameObject.SetActive(false);
}
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 SyncChoicePrompt()
{
if (_choiceOverlay == null || _sending)
return;
var choice = _view?.Choice;
if (choice == null
|| _view == null
|| _view.ActivePlayer != _view.You
|| !IsRearGuardChoice())
{
HideChoiceOverlay();
return;
}
var key = (choice.Prompt ?? string.Empty) + "|" +
string.Join(",", choice.Options.Select(option => option.Slot));
if (_choiceOverlay.gameObject.activeSelf
&& string.Equals(_choicePromptKey, key, StringComparison.Ordinal))
return;
_choicePromptKey = key;
_choiceOverlayTitle.text = choice.Prompt ?? "Выберите круг";
UiKit.MakeLiteral(_choiceOverlayTitle);
ClearChildren(_choiceOverlayButtons);
for (var i = 0; i < choice.Options.Count; i++)
{
var index = i;
var option = choice.Options[i];
var button = UiKit.CreateButton(
_choiceOverlayButtons,
"Choice_" + option.Slot,
string.IsNullOrWhiteSpace(option.Label)
? "Круг " + (option.Slot + 1)
: option.Label,
() =>
{
HideChoiceOverlay();
OnIntentClicked(
new ChooseIntent { Index = index },
ignoreInputLock: true);
},
16);
UiKit.SetPreferredHeight(button, 46f);
}
_choiceOverlay.SetAsLastSibling();
_choiceOverlay.gameObject.SetActive(true);
_inputLock.Acquire("choice");
}
private void HideChoiceOverlay()
{
if (_choiceOverlay == null)
return;
_choicePromptKey = null;
_choiceOverlay.gameObject.SetActive(false);
_inputLock.Release("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()
{
var shift = _intentPanelVisible ? 0f : IntentPanelShift;
if (_phasePanel != null)
_phasePanel.anchoredPosition = new Vector2(-468f + shift, 0f);
if (_opponentZones?.Root != null)
_opponentZones.Root.anchoredPosition = new Vector2(-488f + shift, -108f);
if (_board3D != null)
{
_board3D.SetHandViewportX(
_intentPanelVisible
? (_board3D.Config != null
? _board3D.Config.handViewportX
: 0.44f)
: 0.5f);
}
}
private void OnConcedeClicked()
{
if (_view == null || _finished)
return;
if (!_confirmConcede)
{
_confirmConcede = true;
_confirmDeadline = Time.unscaledTime + 4f;
_concedeLabel.text = "Подтвердить сдачу";
SetStatus("Нажмите «Подтвердить сдачу» ещё раз.", new Color(1f, 0.76f, 0.38f));
return;
}
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
OnIntentClicked(new ConcedeIntent());
}
private void OnReconnectClicked()
{
if (_info == null)
return;
BeginReconnect("Переподключение…");
}
private void OnExitClicked()
{
Hide();
_onExit();
}
private void Render()
{
if (_view == null)
return;
_cardMenu?.Hide();
_board3D?.SetSelfPlayer(_view.You);
_board3D?.Render(_view, BuildBoardRenderState());
UpdateCombatStats();
UpdateZoneStrips();
RebuildIntents(_view);
RebuildPhaseControls(_view);
UpdateClock();
if (_view.IsFinished)
ShowResult(_view);
}
private BoardRenderState BuildBoardRenderState()
{
var state = new BoardRenderState
{
MulliganSelecting = IsMulliganSelecting(),
DamageChoice = IsDamageChoice(),
AbilityDamageTargeting = IsAbilityDamageTargeting(),
AbilityDamageIndex = _abilityDamageIndex,
MatchFinished = _view != null && _view.IsFinished,
};
foreach (var key in _highlightSlots)
state.Highlights.Add(key);
if (_view != null)
{
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));
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 || _discardChoiceActive)
return;
if (IsRearGuardChoice() && slot.Player == _view.You)
{
var index = _view.Choice.Options.FindIndex(
option => option.Slot == slot.Slot);
if (index >= 0)
{
HideChoiceOverlay();
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)
{
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 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, -98f);
_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 (_actionPrompt == null || _view == null || _view.IsFinished)
{
HideActionPrompt();
return;
}
string title = null;
string hint = null;
if (_discardChoiceActive)
{
title = "СБРОС КАРТЫ";
hint = string.IsNullOrWhiteSpace(_discardPrompt)
? "Выберите карту в руке для сброса."
: _discardPrompt;
}
else if (_pendingIntents.Count > 0)
{
title = "ВЫБОР ЦЕЛИ";
hint = _pendingIntents.Any(intent => intent is ResolveTriggerIntent)
? "Выберите цель силы или эффекта триггера."
: "Выберите цель на поле.";
}
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 = "Выберите, как применить эффект триггера.";
}
else if (_view.Choice != null && _view.ActivePlayer == _view.You)
{
title = IsRearGuardChoice() ? "ВЫБОР КРУГА" : "ВЫБОР";
hint = _view.Choice.Prompt;
}
else if (_view.ActivePlayer != _view.You)
{
HideActionPrompt();
return;
}
else if (_view.Phase == GamePhase.Mulligan)
{
title = "МУЛИГАН";
hint = "Выберите карты в руке, затем замените их или закончите муллиган.";
}
else if (_view.Phase == GamePhase.Ride)
{
title = "СМЕНА";
hint = "Выберите карту для смены или пропустите смену.";
}
else if (_view.Step == GameStep.Guard)
{
title = "ЗАЩИТА";
hint = "Выберите защиту или не защищайтесь.";
}
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(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))
// 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)
{
_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;
if (_view != null)
RebuildPhaseControls(_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);
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
&& intent is not ResolveAbilityIntent
&& !(intent is ChooseIntent && IsRearGuardChoice()))
.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;
}
}
}
}