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

1759 lines
67 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.Meta;
using Vanguard.Shared.Protocol;
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 readonly MetaService _meta;
private readonly Action _onExit;
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
new Dictionary<string, CardCatalogItemDto>(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<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 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 HashSet<string> _highlightKeys = new HashSet<string>(StringComparer.Ordinal);
private int _pendingBoosterSlot = -1;
private int? _triggerPowerSlot;
private readonly Dictionary<string, RectTransform> _cardPanels =
new Dictionary<string, RectTransform>(StringComparer.Ordinal);
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
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.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<Text>();
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<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);
_root.gameObject.SetActive(false);
}
public void Show(MatchConnectionInfo info)
{
_info = info;
_view = 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);
_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 (t >= 1f)
{
animation.Target.localScale = Vector3.one;
_cardAnimations.RemoveAt(i);
}
}
}
private void AnimateCardIn(RectTransform target)
{
if (target == null)
return;
target.localScale = Vector3.one * 0.86f;
_cardAnimations.Add(new CardAnimation(target, 0.18f, 0.86f));
}
private void PulseCard(int player, int slot, float startScale)
{
if (!_cardPanels.TryGetValue(CardKey(player, slot), out var target) || target == null)
return;
for (var i = _cardAnimations.Count - 1; i >= 0; i--)
{
if (_cardAnimations[i].Target == target)
_cardAnimations.RemoveAt(i);
}
target.localScale = Vector3.one * startScale;
_cardAnimations.Add(new CardAnimation(target, 0.28f, startScale));
}
private static string CardKey(int player, int slot) => player + ":" + slot;
private sealed class CardAnimation
{
public CardAnimation(RectTransform target, float duration, float startScale)
{
Target = target;
Duration = duration;
StartScale = startScale;
}
public RectTransform Target { get; }
public float Duration { get; }
public float StartScale { 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;
_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;
ClearChildren(_board);
_cardPanels.Clear();
_cardAnimations.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);
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
gridElement.flexibleWidth = 1f;
gridElement.flexibleHeight = 1f;
var layout = grid.gameObject.AddComponent<GridLayoutGroup>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
element.preferredHeight = height;
element.flexibleWidth = 1f;
var outline = pile.gameObject.AddComponent<Outline>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<LayoutElement>();
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<Button>();
button.targetGraphic = panel.GetComponent<Image>();
button.onClick.AddListener(() => OnBoardTargetClicked(new BoardTarget(
playerIndex, slotIndex, card != null ? card.PrintingId : null,
card != null ? card.InstanceId : 0, slotIndex == -2)));
var outline = panel.gameObject.AddComponent<Outline>();
outline.effectColor = highlighted ? new Color(1f, 0.82f, 0.28f) : frameColor;
outline.effectDistance = highlighted
? new Vector2(3f, -3f)
: new Vector2(2f, -2f);
_cardPanels[CardKey(playerIndex, slotIndex)] = panel;
if (card != null)
AnimateCardIn(panel);
UiKit.AddVerticalLayout(panel, 4, 2);
var slotText = UiKit.CreateText(
panel, "Slot", slot, 10,
new Color(0.58f, 0.66f, 0.80f), TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(slotText, 14f);
if (card == null)
{
var panelImage = panel.GetComponent<Image>();
panelImage.sprite = CardArt.Zone;
panelImage.color = Color.white;
panelImage.preserveAspect = true;
var empty = UiKit.CreateText(
panel, "EmptyLabel", "Пусто", 12,
new Color(1f, 1f, 1f, 0.40f), TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(empty, compact ? 100f : 116f);
return;
}
var art = UiKit.CreatePanel(panel, "Art", Color.white);
UiKit.SetPreferredHeight(art, compact ? 46f : 58f);
var artImage = art.GetComponent<Image>();
if (card.FaceUp)
{
CardImageService.Apply(
artImage,
meta != null ? meta.ImageKey : null,
CardArt.ForCard(
meta != null ? meta.NameId : card.PrintingId,
meta != null ? meta.Clan : "None"));
}
else
{
artImage.sprite = CardArt.Back;
artImage.color = Color.white;
artImage.preserveAspect = false;
}
if (card.FaceUp)
{
var artName = UiKit.CreateText(
art, "ArtName", meta != null ? meta.NameEn : card.PrintingId, 10,
new Color(1f, 1f, 1f, 0.94f), TextAnchor.MiddleCenter);
UiKit.Stretch(artName.rectTransform);
artName.rectTransform.offsetMin = new Vector2(3f, 3f);
artName.rectTransform.offsetMax = new Vector2(-3f, -2f);
artName.horizontalOverflow = HorizontalWrapMode.Wrap;
if (meta != null)
{
var grade = UiKit.CreateText(
art, "Grade", "G" + meta.Grade, 10,
Color.white, TextAnchor.MiddleCenter);
var gradeRect = grade.rectTransform;
gradeRect.anchorMin = new Vector2(1f, 1f);
gradeRect.anchorMax = new Vector2(1f, 1f);
gradeRect.pivot = new Vector2(1f, 1f);
gradeRect.anchoredPosition = new Vector2(-3f, -3f);
gradeRect.sizeDelta = new Vector2(25f, 16f);
}
}
else
{
var hidden = UiKit.CreateText(
art, "Hidden", "?", 26, new Color(1f, 1f, 1f, 0.8f));
UiKit.Stretch(hidden.rectTransform);
}
if (card.Rested)
{
var rested = UiKit.CreateText(
art, "Rested", "REST", 10,
new Color(1f, 0.55f, 0.35f), TextAnchor.MiddleCenter);
var restedRect = rested.rectTransform;
restedRect.anchorMin = new Vector2(0f, 0f);
restedRect.anchorMax = new Vector2(0f, 0f);
restedRect.pivot = new Vector2(0f, 0f);
restedRect.anchoredPosition = new Vector2(3f, 3f);
restedRect.sizeDelta = new Vector2(34f, 16f);
}
var name = UiKit.CreateText(
panel, "Name",
meta != null ? meta.NameEn : card.PrintingId,
10,
card.FaceUp ? Color.white : Muted,
TextAnchor.UpperLeft);
name.horizontalOverflow = HorizontalWrapMode.Wrap;
name.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(name, compact ? 22f : 24f);
var stats = UiKit.CreateText(
panel, "Stats",
card.FaceUp
? "P " + card.Power + " · C " + card.Critical
+ (meta != null ? " · " + TriggerShort(meta.Trigger) : string.Empty)
: "СКРЫТА",
10,
card.FaceUp && card.Power != card.BasePower
? new Color(1f, 0.82f, 0.42f)
: new Color(0.72f, 0.78f, 0.92f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(stats, 16f);
}
private static string TriggerShort(string trigger) => trigger switch
{
"Critical" => "K",
"Draw" => "D",
"Stand" => "S",
"Heal" => "H",
_ => "—",
};
private string CardName(string printingId) =>
_catalog.TryGetValue(printingId, out var meta) ? meta.NameEn : printingId;
private void OnBoardTargetClicked(BoardTarget target)
{
if (_view == null || _sending)
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();
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)
{
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();
_highlightKeys.Clear();
_pendingBoosterSlot = -1;
_triggerPowerSlot = null;
if (rebuild && _view != null)
RebuildIntents(_view);
}
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()
{
_highlightKeys.Clear();
if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent))
{
foreach (var trigger in _pendingIntents.OfType<ResolveTriggerIntent>())
{
var slot = _triggerPowerSlot == null
? trigger.PowerTarget
: trigger.EffectTarget;
var card = CardAt(_view.You, slot);
_highlightKeys.Add(TargetKey(
_view.You, slot, card != null ? card.PrintingId : null));
}
return;
}
foreach (var intent in _pendingIntents)
{
if (TryGetTarget(intent, out var target))
_highlightKeys.Add(TargetKey(target.Player, target.Slot, target.PrintingId));
if (intent is AttackIntent attack && attack.BoosterSlot >= 0 && _view != null)
{
var booster = CardAt(_view.You, attack.BoosterSlot);
_highlightKeys.Add(TargetKey(
_view.You,
attack.BoosterSlot,
booster != null ? booster.PrintingId : null));
}
}
}
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 static string TargetKey(int player, int slot, string printingId) =>
player + ":" + slot + ":" + (printingId ?? string.Empty);
private static 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);
var legal = view.LegalIntents ?? new List<IntentPayload>();
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
var board = playable.Where(IsBoardIntent).ToList();
var global = playable.Where(intent => !IsBoardIntent(intent)).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 (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);
}
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);
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 "Пропустить Guard";
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;
}
}
}
}