1759 lines
67 KiB
C#
1759 lines
67 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
using Vanguard.Api;
|
||
using Vanguard.Api.Contracts;
|
||
using Vanguard.Battle;
|
||
using Vanguard.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;
|
||
}
|
||
}
|
||
}
|
||
}
|