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

746 lines
28 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 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 Text _resultText;
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 float _confirmDeadline;
private long _clockServerMs;
private long _clockReceivedMs;
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, 110f);
_reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18);
UiKit.SetPreferredHeight(_reconnect, 46f);
_reconnect.gameObject.SetActive(false);
_result = BuildResultPanel(_root);
_result.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);
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
_ = 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);
_view = null;
}
public void Tick()
{
if (_confirmConcede && Time.unscaledTime > _confirmDeadline)
{
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
}
UpdateClock();
}
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 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;
if (_view.Clock != null)
{
_clockServerMs = _view.Clock.ServerTimeMs;
_clockReceivedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
Render();
}
private void OnEvents(ServerEvents events)
{
_ = RequestSnapshotAsync();
}
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;
_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);
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);
AddPlayerBlock(opponent, false);
AddMatchInfo(_view);
AddPlayerBlock(self, true);
AddHand(self);
RebuildIntents(_view);
UpdateClock();
if (_view.IsFinished)
ShowResult(_view);
}
private void AddPlayerBlock(PlayerView player, bool self)
{
if (player == null)
return;
var block = UiKit.CreatePanel(
_board,
self ? "SelfBlock" : "OpponentBlock",
Panel);
var blockElement = block.gameObject.AddComponent<LayoutElement>();
blockElement.preferredHeight = self ? 250f : 230f;
UiKit.AddVerticalLayout(block, 8, 4);
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,
17,
self ? Color.white : new Color(0.86f, 0.80f, 0.72f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(header, 26f);
var row = UiKit.CreatePanel(block, "Field", new Color(0f, 0f, 0f, 0f));
var rowElement = row.gameObject.AddComponent<LayoutElement>();
rowElement.flexibleHeight = 1f;
rowElement.minHeight = 150f;
UiKit.AddHorizontalLayout(row, 0, 6);
AddCardBox(row, player.Vanguard, "Vanguard");
for (var i = 0; i < 5; i++)
{
var card = player.RearGuards != null && i < player.RearGuards.Length
? player.RearGuards[i]
: null;
AddCardBox(row, card, "R" + (i + 1));
}
}
private void AddHand(PlayerView self)
{
var hand = UiKit.CreatePanel(_board, "HandBlock", Panel);
var handElement = hand.gameObject.AddComponent<LayoutElement>();
handElement.preferredHeight = 190f;
UiKit.AddVerticalLayout(hand, 8, 4);
var header = UiKit.CreateText(
hand, "Header", "Рука", 17, Color.white, TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(header, 22f);
var row = UiKit.CreatePanel(hand, "Cards", new Color(0f, 0f, 0f, 0f));
var rowElement = row.gameObject.AddComponent<LayoutElement>();
rowElement.flexibleHeight = 1f;
UiKit.AddHorizontalLayout(row, 0, 6);
if (self?.Hand == null || self.Hand.Count == 0)
{
var empty = UiKit.CreateText(row, "Empty", "Рука пуста", 18, Muted);
UiKit.SetPreferredHeight(empty, 120f);
return;
}
foreach (var card in self.Hand)
AddCardBox(row, card, "Рука");
}
private void AddMatchInfo(MatchView view)
{
var info = UiKit.CreatePanel(_board, "MatchInfo", Panel);
var element = info.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = 150f;
UiKit.AddVerticalLayout(info, 10, 3);
var active = view.ActivePlayer == view.You ? "вы" : "противник";
AddInfoLine(info, "Фаза: " + view.Phase + " · шаг: " + view.Step
+ " · ход " + view.TurnNumber + " · решает: " + active);
if (view.Battle != null)
{
AddInfoLine(
info,
"Атака: " + SlotName(view.Battle.AttackerSlot)
+ " → " + SlotName(view.Battle.TargetSlot)
+ " · щит " + view.Battle.GuardShield
+ " · проверок " + view.Battle.ChecksRemaining
+ (view.Battle.Nullified ? " · отменено" : string.Empty));
}
if (view.Choice != null)
AddInfoLine(info, "Выбор: " + view.Choice.Prompt);
if (view.Ability != null)
AddInfoLine(
info,
"Способность: " + view.Ability.PrintingId
+ (view.Ability.Optional ? " (опционально)" : string.Empty));
if (view.Trigger != null)
{
AddInfoLine(
info,
"Триггер: " + view.Trigger.PrintingId
+ " / " + view.Trigger.Trigger
+ " / урон " + view.Trigger.RemainingDamage);
}
if (view.Order != null && view.Order.Options.Count > 0)
AddInfoLine(info, "Нужно выбрать порядок эффектов: " + view.Order.Options.Count);
if (view.OpponentChoosing)
AddInfoLine(info, "Противник что-то выбирает…");
}
private static void AddInfoLine(Transform parent, string text)
{
var line = UiKit.CreateText(
parent, "Info", text, 16, Muted, TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(line, 22f);
}
private static void AddCardBox(Transform parent, CardView card, string slot)
{
var panel = UiKit.CreatePanel(parent, "Card_" + slot, Card);
var element = panel.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = 132f;
element.preferredHeight = 148f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(panel, 5, 2);
var slotText = UiKit.CreateText(panel, "Slot", slot, 12, Muted);
UiKit.SetPreferredHeight(slotText, 16f);
if (card == null)
{
var empty = UiKit.CreateText(panel, "Empty", "Пусто", 14, new Color(1f, 1f, 1f, 0.35f));
UiKit.SetPreferredHeight(empty, 60f);
return;
}
var printing = UiKit.CreateText(
panel, "Printing", card.PrintingId, 12, Color.white, TextAnchor.UpperCenter);
printing.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(printing, 42f);
var stats = UiKit.CreateText(
panel, "Stats", card.Power + " / " + card.Critical, 16, Color.white);
UiKit.SetPreferredHeight(stats, 22f);
var stateText = (card.Rested ? "REST " : string.Empty)
+ (card.FaceUp ? string.Empty : "FACE-DOWN");
if (!string.IsNullOrEmpty(stateText))
{
var state = UiKit.CreateText(panel, "State", stateText.Trim(), 12, new Color(1f, 0.76f, 0.38f));
UiKit.SetPreferredHeight(state, 18f);
}
}
private void RebuildIntents(MatchView view)
{
ClearChildren(_intents);
var legal = view.LegalIntents ?? new List<IntentPayload>();
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
if (playable.Count == 0)
{
var waiting = UiKit.CreateText(
_intents,
"Waiting",
view.IsFinished ? "Матч завершён." : "Сейчас решает противник…",
18,
Muted);
UiKit.SetPreferredHeight(waiting, 110f);
return;
}
foreach (var intent in playable)
{
var captured = intent;
var button = UiKit.CreateButton(
_intents,
"Intent_" + captured.GetType().Name,
Label(captured),
() => OnIntentClicked(captured),
17);
UiKit.SetPreferredHeight(button, 50f);
}
}
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--)
UnityEngine.Object.Destroy(parent.GetChild(i).gameObject);
}
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: " + ride.PrintingId;
case CallIntent call: return "Call " + 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: " + 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;
}
}
}