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.Meta;
using Vanguard.Shared.Protocol;
namespace Vanguard.UI
{
///
/// Серый боевой экран: доска, рука, часы и кнопки всех легальных интентов.
/// Красоту и анимации добавим поверх этой рабочей логики.
///
public sealed class BattleView
{
private static readonly Color Background = new Color(0.035f, 0.045f, 0.065f, 1f);
private static readonly Color Panel = new Color(0.07f, 0.085f, 0.115f, 1f);
private static readonly Color Card = new Color(0.10f, 0.13f, 0.18f, 1f);
private static readonly Color Muted = new Color(0.62f, 0.68f, 0.82f);
private static readonly Color Accent = new Color(0.20f, 0.42f, 0.78f, 1f);
private readonly MetaService _meta;
private readonly Action _onExit;
private readonly Dictionary _catalog =
new Dictionary(StringComparer.Ordinal);
private bool _catalogLoading;
private readonly RectTransform _root;
private readonly RectTransform _board;
private RectTransform _intents;
private readonly Text _topInfo;
private readonly Text _clock;
private readonly Text _status;
private readonly Button _concede;
private readonly Text _concedeLabel;
private readonly Button _reconnect;
private readonly RectTransform _result;
private RectTransform _logContent;
private Text _resultText;
private Text _feedback;
private Image _flash;
private float _feedbackTime;
private float _feedbackDuration;
private Color _feedbackColor;
private readonly Queue _feedbackQueue = new Queue();
private Button _skipFeedback;
private bool _feedbackPlaying;
private readonly List _logLines = new List();
private GameConnection _connection;
private CancellationTokenSource _cts;
private MatchConnectionInfo _info;
private MatchView _view;
private MatchView _previousView;
private bool _connecting;
private bool _sending;
private bool _snapshotPending;
private bool _finished;
private bool _confirmConcede;
private bool _showAllIntents;
private float _confirmDeadline;
private long _clockServerMs;
private long _clockReceivedMs;
private readonly List _pendingIntents = new List();
private readonly HashSet _highlightKeys = new HashSet(StringComparer.Ordinal);
private int _pendingBoosterSlot = -1;
private int? _triggerPowerSlot;
private readonly Dictionary _cardPanels =
new Dictionary(StringComparer.Ordinal);
private readonly List _cardAnimations = new List();
private readonly List _pendingEntries = new List();
private readonly List _ghostAnimations = new List();
private readonly Dictionary _cardPrintings =
new Dictionary(StringComparer.Ordinal);
private readonly Dictionary _cardFaceUp =
new Dictionary(StringComparer.Ordinal);
private readonly Dictionary _cardAnchors =
new Dictionary(StringComparer.Ordinal);
private RectTransform _fxLayer;
public BattleView(Transform parent, MetaService meta, Action onExit)
{
_meta = meta;
_onExit = onExit;
_root = UiKit.CreatePanel(parent, "BattleScreen", Background);
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);
_concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18);
_concedeLabel = _concede.GetComponentInChildren();
UiKit.SetPreferredHeight(_concede, 44f, 0f);
var left = UiKit.CreatePanel(_root, "BoardPanel", Background);
left.anchorMin = Vector2.zero;
left.anchorMax = Vector2.one;
left.offsetMin = new Vector2(36f, 28f);
left.offsetMax = new Vector2(-564f, -108f);
UiKit.AddVerticalLayout(left, 8, 8);
_board = left;
var right = UiKit.CreatePanel(_root, "IntentPanel", Panel);
right.anchorMin = new Vector2(1f, 0f);
right.anchorMax = new Vector2(1f, 1f);
right.pivot = new Vector2(1f, 0.5f);
right.offsetMin = new Vector2(-528f, 28f);
right.offsetMax = new Vector2(-36f, -108f);
UiKit.AddVerticalLayout(right, 14, 10);
var actionsTitle = UiKit.CreateText(
right, "ActionsTitle", "Действия", 24, Color.white, TextAnchor.MiddleLeft);
actionsTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(actionsTitle, 32f);
var scroll = UiKit.CreateScrollView(right, "IntentScroll", out _intents, 6f);
var scrollElement = scroll.gameObject.AddComponent();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 200f;
_status = UiKit.CreateText(
right, "Status", string.Empty, 16, Muted, TextAnchor.UpperLeft);
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(_status, 74f);
_skipFeedback = UiKit.CreateButton(
right, "SkipFeedback", "Пропустить анимации", SkipFeedback, 15);
UiKit.SetPreferredHeight(_skipFeedback, 38f);
_skipFeedback.gameObject.SetActive(false);
var logTitle = UiKit.CreateText(
right, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
logTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(logTitle, 22f);
var logScroll = UiKit.CreateScrollView(right, "LogScroll", out _logContent, 3f);
var logElement = logScroll.gameObject.AddComponent();
logElement.preferredHeight = 142f;
logElement.minHeight = 80f;
_reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18);
UiKit.SetPreferredHeight(_reconnect, 46f);
_reconnect.gameObject.SetActive(false);
_result = BuildResultPanel(_root);
_result.gameObject.SetActive(false);
_fxLayer = UiKit.CreatePanel(_root, "BattleFx", Color.clear);
_fxLayer.GetComponent().raycastTarget = false;
_flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent();
_flash.raycastTarget = false;
_flash.gameObject.SetActive(false);
_feedback = UiKit.CreateText(
_root, "BattleFeedback", string.Empty, 44,
Color.white, TextAnchor.MiddleCenter);
_feedback.fontStyle = FontStyle.Bold;
_feedback.raycastTarget = false;
var feedbackRect = _feedback.rectTransform;
feedbackRect.anchorMin = new Vector2(0.5f, 0.5f);
feedbackRect.anchorMax = new Vector2(0.5f, 0.5f);
feedbackRect.pivot = new Vector2(0.5f, 0.5f);
feedbackRect.sizeDelta = new Vector2(900f, 120f);
feedbackRect.anchoredPosition = new Vector2(0f, 130f);
_feedback.gameObject.SetActive(false);
_root.gameObject.SetActive(false);
}
public void Show(MatchConnectionInfo info)
{
_info = info;
_view = null;
_previousView = null;
_finished = false;
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
_concede.interactable = true;
_reconnect.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_pendingIntents.Clear();
_highlightKeys.Clear();
_pendingBoosterSlot = -1;
_triggerPowerSlot = null;
_showAllIntents = false;
_logLines.Clear();
RebuildLog();
_feedbackQueue.Clear();
_feedbackPlaying = false;
_feedbackTime = 0f;
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
ClearGhosts();
_cardAnchors.Clear();
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
_ = LoadCatalogAsync();
_ = ConnectAsync();
}
public void Hide()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
if (_connection != null)
{
var connection = _connection;
_connection = null;
_ = connection.DisposeAsync().AsTask();
}
_root.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
_view = null;
}
public void Tick()
{
if (_confirmConcede && Time.unscaledTime > _confirmDeadline)
{
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
}
UpdateClock();
UpdateFeedback();
UpdateCardAnimations();
}
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);
return;
}
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 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 UpdateCardAnimations()
{
for (var i = _cardAnimations.Count - 1; i >= 0; i--)
{
var animation = _cardAnimations[i];
if (animation.Target == null)
{
_cardAnimations.RemoveAt(i);
continue;
}
animation.Time += Time.unscaledDeltaTime;
var t = Mathf.Clamp01(animation.Time / animation.Duration);
var eased = 1f - Mathf.Pow(1f - t, 3f);
var scale = Mathf.Lerp(animation.StartScale, 1f, eased);
animation.Target.localScale = new Vector3(scale, scale, 1f);
if (animation.Move)
{
animation.Target.anchoredPosition = Vector2.Lerp(
animation.StartPosition,
animation.Destination,
eased);
}
if (t >= 1f)
{
animation.Target.localScale = Vector3.one;
if (animation.Move)
animation.Target.anchoredPosition = animation.Destination;
_cardAnimations.RemoveAt(i);
}
}
UpdateGhostAnimations();
}
private void UpdateGhostAnimations()
{
for (var i = _ghostAnimations.Count - 1; i >= 0; i--)
{
var ghost = _ghostAnimations[i];
if (ghost.Target == null)
{
_ghostAnimations.RemoveAt(i);
continue;
}
ghost.Time += Time.unscaledDeltaTime;
var t = Mathf.Clamp01(ghost.Time / ghost.Duration);
var eased = 1f - Mathf.Pow(1f - t, 2f);
ghost.Target.position = Vector3.Lerp(ghost.Start, ghost.End, eased);
ghost.Target.localScale = Vector3.one * Mathf.Lerp(1f, 0.82f, eased);
if (ghost.Group != null)
ghost.Group.alpha = 1f - eased;
if (t < 1f)
continue;
if (ghost.Target != null)
UnityEngine.Object.Destroy(ghost.Target.gameObject);
_ghostAnimations.RemoveAt(i);
}
}
private void ClearGhosts()
{
foreach (var ghost in _ghostAnimations)
{
if (ghost.Target != null)
UnityEngine.Object.Destroy(ghost.Target.gameObject);
}
_ghostAnimations.Clear();
}
private void CaptureCardAnchors()
{
foreach (var pair in _cardPrintings)
{
if (!_cardPanels.TryGetValue(pair.Key, out var panel) || panel == null)
continue;
_cardFaceUp.TryGetValue(pair.Key, out var faceUp);
_cardAnchors[pair.Key] = new CardAnchor(
panel.position,
panel.rect.size,
pair.Value,
faceUp);
}
}
private void SpawnRetireGhosts(Dictionary oldAnchors)
{
if (oldAnchors.Count == 0 || _fxLayer == null)
return;
foreach (var pair in oldAnchors)
{
if (_cardAnchors.ContainsKey(pair.Key))
continue;
// Если карта просто переехала в другой слот, не считаем это уходом в дроп.
if (_cardAnchors.Values.Any(item => item.PrintingId == pair.Value.PrintingId))
continue;
CreateRetireGhost(pair.Key, pair.Value);
}
}
private void CreateRetireGhost(string key, CardAnchor anchor)
{
var panel = UiKit.CreatePanel(_fxLayer, "Retired_" + key, Color.white);
panel.anchorMin = new Vector2(0.5f, 0.5f);
panel.anchorMax = new Vector2(0.5f, 0.5f);
panel.pivot = new Vector2(0.5f, 0.5f);
panel.sizeDelta = anchor.Size;
panel.position = anchor.World;
var image = panel.GetComponent();
image.raycastTarget = false;
var meta = _catalog.TryGetValue(anchor.PrintingId, out var found) ? found : null;
if (anchor.FaceUp)
{
CardImageService.Apply(
image,
meta != null ? meta.ImageKey : null,
CardArt.ForCard(
meta != null ? meta.NameId : anchor.PrintingId,
meta != null ? meta.Clan : "None"));
}
else
{
image.sprite = CardArt.Back;
image.color = Color.white;
}
var outline = panel.gameObject.AddComponent();
outline.effectColor = new Color(1f, 0.35f, 0.28f, 0.9f);
outline.effectDistance = new Vector2(2f, -2f);
var group = panel.gameObject.AddComponent();
group.blocksRaycasts = false;
group.interactable = false;
var player = 0;
var split = key.IndexOf(':');
if (split > 0)
int.TryParse(key.Substring(0, split), out player);
var direction = _view != null && player == _view.You ? -1f : 1f;
_ghostAnimations.Add(new GhostAnimation(
panel,
group,
anchor.World,
anchor.World + new Vector3(0f, direction * 190f, 0f),
0.36f));
}
private void FinalizeCardEntries()
{
// Сначала даём layout-группам посчитать конечные позиции карт.
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_board);
CaptureCardAnchors();
if (_pendingEntries.Count == 0)
return;
foreach (var entry in _pendingEntries)
{
if (entry.Target == null)
continue;
var destination = entry.Target.anchoredPosition;
var start = destination + entry.Offset;
entry.Target.anchoredPosition = start;
_cardAnimations.Add(new CardAnimation(
entry.Target,
0.24f,
entry.StartScale,
start,
destination,
move: true));
}
_pendingEntries.Clear();
}
private void AnimateCardIn(RectTransform target, Vector2 offset)
{
if (target == null)
return;
target.localScale = Vector3.one * 0.86f;
_pendingEntries.Add(new CardEntry(target, offset, 0.86f));
}
private void PulseCard(int player, int slot, float startScale)
{
if (!_cardPanels.TryGetValue(CardKey(player, slot), out var target) || target == null)
return;
target.localScale = Vector3.one * startScale;
_cardAnimations.Add(new CardAnimation(
target, 0.28f, startScale, target.anchoredPosition, target.anchoredPosition, move: false));
}
private bool ShouldAnimateCard(int player, int slot, string printingId)
{
if (_previousView == null)
return true;
var side = _previousView.Players.FirstOrDefault(item => item.Index == player);
if (side == null)
return true;
if (slot == -2)
{
return side.Hand == null
|| side.Hand.All(item => item.PrintingId != printingId);
}
if (slot == -1)
return side.Vanguard == null || side.Vanguard.PrintingId != printingId;
return side.RearGuards == null
|| slot >= side.RearGuards.Length
|| side.RearGuards[slot] == null
|| side.RearGuards[slot].PrintingId != printingId;
}
private static string CardKey(int player, int slot) => player + ":" + slot;
private readonly struct CardAnchor
{
public CardAnchor(Vector3 world, Vector2 size, string printingId, bool faceUp)
{
World = world;
Size = size;
PrintingId = printingId;
FaceUp = faceUp;
}
public Vector3 World { get; }
public Vector2 Size { get; }
public string PrintingId { get; }
public bool FaceUp { get; }
}
private sealed class GhostAnimation
{
public GhostAnimation(
RectTransform target,
CanvasGroup group,
Vector3 start,
Vector3 end,
float duration)
{
Target = target;
Group = group;
Start = start;
End = end;
Duration = duration;
}
public RectTransform Target { get; }
public CanvasGroup Group { get; }
public Vector3 Start { get; }
public Vector3 End { get; }
public float Duration { get; }
public float Time { get; set; }
}
private sealed class CardEntry
{
public CardEntry(RectTransform target, Vector2 offset, float startScale)
{
Target = target;
Offset = offset;
StartScale = startScale;
}
public RectTransform Target { get; }
public Vector2 Offset { get; }
public float StartScale { get; }
}
private sealed class CardAnimation
{
public CardAnimation(
RectTransform target,
float duration,
float startScale,
Vector2 startPosition,
Vector2 destination,
bool move)
{
Target = target;
Duration = duration;
StartScale = startScale;
StartPosition = startPosition;
Destination = destination;
Move = move;
}
public RectTransform Target { get; }
public float Duration { get; }
public float StartScale { get; }
public Vector2 StartPosition { get; }
public Vector2 Destination { get; }
public bool Move { get; }
public float Time { get; set; }
}
private void SkipFeedback()
{
_feedbackQueue.Clear();
_feedbackPlaying = false;
_feedbackTime = 0f;
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
}
private readonly struct FeedbackStep
{
public FeedbackStep(string text, Color color, float duration, bool flash)
{
Text = text;
Color = color;
Duration = duration;
Flash = flash;
}
public string Text { get; }
public Color Color { get; }
public float Duration { get; }
public bool Flash { get; }
}
private RectTransform BuildResultPanel(Transform parent)
{
var overlay = UiKit.CreatePanel(parent, "BattleResult", new Color(0f, 0f, 0f, 0.75f));
var card = UiKit.CreateCenteredCard(
overlay, "ResultCard", new Vector2(640f, 330f),
new Color(0.08f, 0.10f, 0.14f, 1f));
UiKit.AddVerticalLayout(card, 32, 14);
var title = UiKit.CreateText(card, "Title", "Матч завершён", 32, Color.white);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 48f);
_resultText = UiKit.CreateText(card, "Text", string.Empty, 22, Muted);
UiKit.SetPreferredHeight(_resultText, 90f);
var exit = UiKit.CreateButton(card, "Exit", "В меню", OnExitClicked, 21);
UiKit.SetPreferredHeight(exit, 52f);
return overlay;
}
private async Task LoadCatalogAsync()
{
if (_catalogLoading || _catalog.Count > 0)
return;
_catalogLoading = true;
try
{
var items = await _meta.GetCatalogAsync();
foreach (var item in items)
_catalog[item.PrintingId] = item;
if (_root.gameObject.activeSelf && _view != null)
Render();
}
catch (Exception exception)
{
Debug.LogWarning("Battle catalog load failed: " + exception.Message);
}
finally
{
_catalogLoading = false;
}
}
private async Task ConnectAsync()
{
if (_connecting || _info == null)
return;
_connecting = true;
_reconnect.gameObject.SetActive(false);
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
try
{
if (_connection != null)
{
await _connection.DisposeAsync();
_connection = null;
}
var connection = new GameConnection();
_connection = connection;
connection.HelloReceived += OnHello;
connection.SnapshotReceived += OnSnapshot;
connection.EventsReceived += OnEvents;
connection.ClockReceived += OnClock;
connection.ErrorReceived += OnError;
connection.Closed += OnClosed;
await connection.ConnectAsync(
_info.WsUrl,
_info.Ticket,
Application.version,
_view != null ? _view.EventSeq : 0,
_cts.Token);
SetStatus("Соединение открыто. Ожидаем состояние…", Muted);
}
catch (OperationCanceledException)
{
// экран закрыли
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus("Не удалось подключиться: " + exception.Message, new Color(1f, 0.55f, 0.45f));
_reconnect.gameObject.SetActive(true);
}
finally
{
_connecting = false;
}
}
private void OnHello(ServerHello hello)
{
if (_info != null)
_info.Seat = hello.PlayerIndex;
}
private void OnSnapshot(ServerSnapshot snapshot)
{
if (!_root.gameObject.activeSelf || snapshot?.View == null)
return;
_previousView = _view;
_view = snapshot.View;
_finished = _view.IsFinished;
_pendingIntents.Clear();
_highlightKeys.Clear();
_triggerPowerSlot = null;
PrepareTriggerSelection();
if (_view.Clock != null)
{
_clockServerMs = _view.Clock.ServerTimeMs;
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
Render();
}
private void OnEvents(ServerEvents events)
{
if (events?.Events != null)
{
foreach (var envelope in events.Events)
{
var line = Describe(envelope.Event);
if (!string.IsNullOrWhiteSpace(line))
_logLines.Add(line);
ShowFeedbackFor(envelope.Event);
}
while (_logLines.Count > 40)
_logLines.RemoveAt(0);
RebuildLog();
}
_ = RequestSnapshotAsync();
}
private void RebuildLog()
{
if (_logContent == null)
return;
ClearChildren(_logContent);
var start = Mathf.Max(0, _logLines.Count - 8);
for (var i = start; i < _logLines.Count; i++)
{
var line = UiKit.CreateText(
_logContent, "LogLine", _logLines[i], 13,
i == _logLines.Count - 1
? new Color(0.92f, 0.94f, 1f)
: new Color(0.60f, 0.68f, 0.82f),
TextAnchor.MiddleLeft);
line.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(line, 30f);
}
}
private void ShowFeedbackFor(EventView view)
{
switch (view)
{
case AttackDeclaredView attack:
ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot)
+ " → " + SlotName(attack.TargetSlot),
new Color(1f, 0.82f, 0.35f), 1.0f);
break;
case GuardAddedView guard:
ShowFeedback("GUARD +" + guard.Shield,
new Color(0.45f, 0.82f, 1f), 1.0f);
break;
case DriveCheckedView drive:
ShowFeedback("DRIVE " + TriggerShort(drive.Trigger.ToString()),
new Color(0.72f, 0.88f, 1f), 1.0f);
break;
case TriggerResolvedView trigger:
ShowFeedback("TRIGGER " + trigger.Trigger,
new Color(0.72f, 0.88f, 1f), 1.1f);
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),
0.9f);
break;
case DamageTakenView damage:
ShowFeedback("+" + damage.Amount + " DAMAGE",
new Color(1f, 0.30f, 0.26f), 1.2f, flash: true);
break;
case HealedView:
ShowFeedback("HEAL",
new Color(0.45f, 0.95f, 0.62f), 1.1f, flash: true);
break;
case UnitRetiredView:
ShowFeedback("RETIRE",
new Color(1f, 0.55f, 0.30f), 1.0f);
break;
case PerfectGuardView:
ShowFeedback("PERFECT GUARD",
new Color(0.60f, 0.82f, 1f), 1.1f);
break;
}
}
private string Describe(EventView view)
{
if (view == null)
return null;
return view switch
{
UnitRodeView ride => "Ride: " + CardName(ride.PrintingId),
UnitCalledView call => "Call: " + CardName(call.PrintingId) + " → " + SlotName(call.Slot),
UnitMovedView move => "Move: " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot),
AttackDeclaredView attack => "Атака: " + SlotName(attack.AttackerSlot)
+ " → " + SlotName(attack.TargetSlot),
GuardAddedView guard => "Guard: " + CardName(guard.PrintingId)
+ " (+" + guard.Shield + ")",
PerfectGuardView perfect => "Perfect Guard: " + CardName(perfect.SentinelPrintingId),
DriveCheckedView drive => "Drive: " + CardName(drive.PrintingId)
+ " · " + drive.Trigger,
TriggerResolvedView trigger => "Триггер: " + CardName(trigger.PrintingId),
BattleResolvedView battle => battle.Hit ? "Атака прошла." : "Атака отбита.",
DamageTakenView damage => "Урон: " + PlayerName(damage.Player)
+ " +" + damage.Amount + " (всего " + damage.Total + ")",
HealedView heal => "Heal: " + PlayerName(heal.Player),
UnitRetiredView retire => "Retire: " + SlotName(retire.Slot),
InterceptedView intercept => "Intercept: " + CardName(intercept.PrintingId),
CardDrawnView draw => "Карта: " + PlayerName(draw.Player),
PhaseChangedView phase => "Фаза: " + phase.Phase,
GameFinishedView finish => "Партия завершена.",
_ => null,
};
}
private string PlayerName(int player) =>
_view == null ? "P" + player : player == _view.You ? "Вы" : "Противник";
private void OnClock(ServerClock clock)
{
if (!_root.gameObject.activeSelf || clock?.Clock == null)
return;
_clockServerMs = clock.Clock.ServerTimeMs;
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (_view != null)
_view.Clock = clock.Clock;
UpdateClock();
}
private void OnError(ServerError error)
{
SetStatus(error.Code + ": " + error.Message, new Color(1f, 0.55f, 0.45f));
}
private void OnClosed(string reason)
{
if (!_root.gameObject.activeSelf || _finished)
return;
SetStatus("Соединение потеряно: " + reason, new Color(1f, 0.60f, 0.45f));
_reconnect.gameObject.SetActive(true);
}
private async Task RequestSnapshotAsync()
{
if (_snapshotPending || _connection == null || !_connection.IsConnected)
return;
_snapshotPending = true;
try
{
await _connection.RequestSnapshotAsync(_cts != null ? _cts.Token : CancellationToken.None);
}
catch (OperationCanceledException)
{
// экран закрыли
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus("Не удалось обновить состояние.", new Color(1f, 0.60f, 0.45f));
}
finally
{
_snapshotPending = false;
}
}
private async void OnIntentClicked(IntentPayload intent)
{
if (_sending || _connection == null || _cts == null || _view == null)
return;
_pendingIntents.Clear();
_highlightKeys.Clear();
_pendingBoosterSlot = -1;
_triggerPowerSlot = null;
_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));
}
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 OnConcedeClicked()
{
if (_view == null || _finished)
return;
if (!_confirmConcede)
{
_confirmConcede = true;
_confirmDeadline = Time.unscaledTime + 4f;
_concedeLabel.text = "Подтвердить сдачу";
SetStatus("Нажмите «Подтвердить сдачу» ещё раз.", new Color(1f, 0.76f, 0.38f));
return;
}
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
OnIntentClicked(new ConcedeIntent());
}
private async void OnReconnectClicked()
{
if (_info == null)
return;
_reconnect.interactable = false;
SetStatus("Получаем новый тикет…", Muted);
try
{
var ticket = await _meta.CreateTicketAsync(_info.MatchId);
_info.Ticket = ticket.Ticket;
_info.WsUrl = ticket.WsUrl;
await ConnectAsync();
}
catch (ApiException exception)
{
SetStatus(exception.Message, new Color(1f, 0.55f, 0.45f));
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus("Переподключение не удалось.", new Color(1f, 0.55f, 0.45f));
}
finally
{
_reconnect.interactable = true;
}
}
private void OnExitClicked()
{
Hide();
_onExit();
}
private void Render()
{
if (_view == null)
return;
var oldAnchors = new Dictionary(_cardAnchors, StringComparer.Ordinal);
ClearChildren(_board);
_cardPanels.Clear();
_cardPrintings.Clear();
_cardFaceUp.Clear();
_cardAnchors.Clear();
_cardAnimations.Clear();
_pendingEntries.Clear();
var opponentIndex = _view.You == 0 ? 1 : 0;
var opponent = _view.Players.FirstOrDefault(player => player.Index == opponentIndex)
?? (_view.Players.Count > opponentIndex ? _view.Players[opponentIndex] : null);
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You)
?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null);
AddSideField(opponent, false);
AddGuardZone(_view);
AddSideField(self, true);
AddHand(self);
FinalizeCardEntries();
SpawnRetireGhosts(oldAnchors);
if (_view.Battle != null)
{
PulseCard(_view.Battle.AttackerPlayer, _view.Battle.AttackerSlot, 1.11f);
PulseCard(1 - _view.Battle.AttackerPlayer, _view.Battle.TargetSlot, 1.11f);
}
RebuildIntents(_view);
UpdateClock();
if (_view.IsFinished)
ShowResult(_view);
}
private void AddSideField(PlayerView player, bool self)
{
if (player == null)
return;
var block = UiKit.CreatePanel(
_board,
self ? "SelfField" : "OpponentField",
new Color(0.05f, 0.07f, 0.11f, 0.96f));
var blockElement = block.gameObject.AddComponent();
blockElement.preferredHeight = 334f;
UiKit.AddVerticalLayout(block, 5, 3);
var header = UiKit.CreateText(
block,
"Header",
(self ? "ВЫ" : "ПРОТИВНИК") + " · " + player.Name
+ " рука " + player.HandCount
+ " · колода " + player.DeckCount
+ " · урон " + (player.FaceDownDamage + player.FaceUpDamage.Count)
+ " · soul " + player.Soul.Count
+ " · drop " + player.Drop.Count,
15,
self ? Color.white : new Color(0.94f, 0.82f, 0.70f),
TextAnchor.MiddleLeft);
header.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(header, 24f);
var body = UiKit.CreatePanel(block, "Body", new Color(0f, 0f, 0f, 0f));
var bodyElement = body.gameObject.AddComponent();
bodyElement.flexibleHeight = 1f;
bodyElement.minHeight = 294f;
UiKit.AddHorizontalLayout(body, 0, 8);
if (self)
{
AddDamageColumn(body, player);
AddFieldGrid(body, player, self);
AddPileColumn(body, player);
}
else
{
AddPileColumn(body, player);
AddFieldGrid(body, player, self);
AddDamageColumn(body, player);
}
}
private void AddFieldGrid(Transform parent, PlayerView player, bool self)
{
var grid = UiKit.CreatePanel(parent, "FieldGrid", new Color(0f, 0f, 0f, 0f));
var gridElement = grid.gameObject.AddComponent();
gridElement.flexibleWidth = 1f;
gridElement.flexibleHeight = 1f;
var layout = grid.gameObject.AddComponent();
layout.cellSize = new Vector2(140f, 140f);
layout.spacing = new Vector2(6f, 6f);
layout.padding = new RectOffset(4, 4, 4, 4);
layout.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
layout.constraintCount = 3;
layout.childAlignment = TextAnchor.MiddleCenter;
layout.startCorner = GridLayoutGroup.Corner.UpperLeft;
layout.startAxis = GridLayoutGroup.Axis.Horizontal;
// Первый ряд: R V R. Второй ряд: R R R.
// Для противника ряды зеркальны: его V-ряд ближе к центру.
var slots = self
? new[] { 3, -1, 4, 0, 1, 2 }
: new[] { 0, 1, 2, 3, -1, 4 };
foreach (var slot in slots)
{
var card = CardAt(player.Index, slot);
AddCardBox(
grid,
card,
slot == -1 ? "V" : slot <= 2 ? "SUP" + (slot + 1) : "R" + (slot + 1),
compact: false,
player.Index,
slot);
}
}
private void AddDamageColumn(Transform parent, PlayerView player)
{
var column = UiKit.CreatePanel(parent, "DamageColumn", new Color(0f, 0f, 0f, 0f));
var element = column.gameObject.AddComponent();
element.preferredWidth = 88f;
element.minWidth = 80f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(column, 0, 5);
AddPile(column, "УРОН",
player.FaceDownDamage + player.FaceUpDamage.Count,
new Color(0.48f, 0.12f, 0.12f), 118f);
AddPile(column, "SOUL", player.Soul.Count,
new Color(0.26f, 0.18f, 0.42f), 96f);
}
private void AddPileColumn(Transform parent, PlayerView player)
{
var column = UiKit.CreatePanel(parent, "PileColumn", new Color(0f, 0f, 0f, 0f));
var element = column.gameObject.AddComponent();
element.preferredWidth = 88f;
element.minWidth = 80f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(column, 0, 5);
AddPile(column, "КОЛОДА", player.DeckCount,
new Color(0.12f, 0.22f, 0.42f), 118f);
AddPile(column, "ДРОП", player.Drop.Count,
new Color(0.20f, 0.20f, 0.26f), 96f);
}
private void AddPile(Transform parent, string title, int count, Color color, float height)
{
var pile = UiKit.CreatePanel(parent, "Pile_" + title, color);
var element = pile.gameObject.AddComponent();
element.preferredHeight = height;
element.flexibleWidth = 1f;
var outline = pile.gameObject.AddComponent();
outline.effectColor = new Color(1f, 1f, 1f, 0.15f);
outline.effectDistance = new Vector2(1f, -1f);
UiKit.AddVerticalLayout(pile, 5, 1);
var label = UiKit.CreateText(
pile, "Title", title, 11, new Color(1f, 1f, 1f, 0.75f));
UiKit.SetPreferredHeight(label, 18f);
var value = UiKit.CreateText(
pile, "Count", count.ToString(), 26, Color.white);
value.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(value, 38f);
}
private void AddGuardZone(MatchView view)
{
var guard = UiKit.CreatePanel(
_board, "GuardZone", new Color(0.08f, 0.11f, 0.18f, 0.98f));
var element = guard.gameObject.AddComponent();
element.preferredHeight = 72f;
UiKit.AddHorizontalLayout(guard, 14, 12);
var label = UiKit.CreateText(
guard,
"Label",
"ЗОНА ЗАЩИТЫ",
18,
new Color(0.82f, 0.88f, 1f),
TextAnchor.MiddleLeft);
label.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(label, 32f, 0f);
var shield = UiKit.CreateText(
guard,
"Shield",
"Щит: " + (view.Battle != null ? view.Battle.GuardShield.ToString() : "—"),
18,
new Color(0.55f, 0.86f, 1f),
TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(shield, 32f, 0f);
var guardian = UiKit.CreateText(
guard,
"Guardian",
"Проверок: " + (view.Battle != null ? view.Battle.ChecksRemaining : 0)
+ " · защита между полями",
14,
Muted,
TextAnchor.MiddleRight);
UiKit.SetPreferredHeight(guardian, 32f);
}
private void AddHand(PlayerView self)
{
var hand = UiKit.CreatePanel(_board, "HandBlock", new Color(0.05f, 0.07f, 0.11f, 0.96f));
var handElement = hand.gameObject.AddComponent();
handElement.preferredHeight = 164f;
UiKit.AddVerticalLayout(hand, 6, 3);
var header = UiKit.CreateText(
hand, "Header",
"РУКА · " + (self?.Hand?.Count ?? 0) + " карт",
16, Color.white, TextAnchor.MiddleLeft);
header.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(header, 22f);
var scroll = UiKit.CreateHorizontalScrollView(hand, "HandScroll", out var content, 6f);
var scrollElement = scroll.gameObject.AddComponent();
scrollElement.flexibleHeight = 1f;
scrollElement.minHeight = 124f;
if (self?.Hand == null || self.Hand.Count == 0)
{
var empty = UiKit.CreateText(
content, "Empty", "Рука пуста", 17, Muted);
UiKit.SetPreferredHeight(empty, 110f);
return;
}
foreach (var card in self.Hand)
AddCardBox(content, card, "Рука", compact: true, self.Index, -2);
}
private void AddInfoLine(Transform parent, string text)
{
var line = UiKit.CreateText(
parent, "Info", text, 16, Muted, TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(line, 22f);
}
private void AddCardBox(
Transform parent,
CardView card,
string slot,
bool compact,
int playerIndex,
int slotIndex)
{
var meta = card != null && _catalog.TryGetValue(card.PrintingId, out var found)
? found
: null;
var clanColor = CardArt.ClanColor(meta != null ? meta.Clan : "None");
var frameColor = card == null
? new Color(0.16f, 0.18f, 0.24f)
: CardArt.ClanColor(meta != null ? meta.Clan : "None");
var panel = UiKit.CreatePanel(
parent, "Card_" + slot, card == null
? new Color(0.06f, 0.08f, 0.12f, 0.9f)
: card.FaceUp
? new Color(0.09f, 0.11f, 0.16f, 1f)
: new Color(0.05f, 0.08f, 0.14f, 1f));
var element = panel.gameObject.AddComponent();
element.preferredWidth = compact ? 104f : 140f;
element.preferredHeight = compact ? 124f : 140f;
element.flexibleWidth = 0f;
var highlighted = _highlightKeys.Contains(
TargetKey(playerIndex, slotIndex, card != null ? card.PrintingId : null));
var button = panel.gameObject.AddComponent