3210 lines
110 KiB
C#
3210 lines
110 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using UnityEngine;
|
||
using UnityEngine.EventSystems;
|
||
using UnityEngine.InputSystem;
|
||
using Vanguard.Battle;
|
||
using Vanguard.Shared.Protocol;
|
||
|
||
namespace Vanguard.Board3D
|
||
{
|
||
/// <summary>
|
||
/// 3D-поле: стол, круги, общий круг защиты, стопки, веер руки и карты.
|
||
/// Отрисовка полностью процедурная; материалы и меши переиспользуются.
|
||
/// </summary>
|
||
public sealed class GameBoard3D : MonoBehaviour
|
||
{
|
||
private const string RootName = "GameBoard3D";
|
||
|
||
private static GameBoard3D _instance;
|
||
|
||
private readonly List<BoardSlotView> _slots = new List<BoardSlotView>();
|
||
private readonly Dictionary<BoardSlotKey, BoardSlotView> _slotByKey =
|
||
new Dictionary<BoardSlotKey, BoardSlotView>();
|
||
private readonly Dictionary<string, Card3D> _cards =
|
||
new Dictionary<string, Card3D>(StringComparer.Ordinal);
|
||
private readonly Stack<Card3D> _pool = new Stack<Card3D>();
|
||
private CardMoveAnimator _moveAnimator;
|
||
private CameraDirector _cameraDirector;
|
||
private readonly List<Pile3D> _piles = new List<Pile3D>();
|
||
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
|
||
private readonly TextMesh[] _damageCounts = new TextMesh[2];
|
||
private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
|
||
private readonly bool[] _triggerZoneVisible = { true, true };
|
||
private int _pendingTriggerRevealPlayer = -1;
|
||
private float _pendingTriggerRevealDuration;
|
||
private readonly HashSet<BoardSlotKey> _activeHighlights = new HashSet<BoardSlotKey>();
|
||
private readonly HashSet<BoardSlotKey> _selectedHighlights = new HashSet<BoardSlotKey>();
|
||
private readonly HashSet<BoardSlotKey> _validDropSlots = new HashSet<BoardSlotKey>();
|
||
private readonly List<HandCardBinding> _handBindings = new List<HandCardBinding>();
|
||
private bool _guardianDropAllowed;
|
||
private bool _handVisible = true;
|
||
private bool _opponentHandVisible = true;
|
||
private readonly List<HandCardBinding> _opponentHandBindings =
|
||
new List<HandCardBinding>();
|
||
private readonly List<Card3D> _drawSelfCards = new List<Card3D>();
|
||
private readonly List<Card3D> _drawOpponentCards = new List<Card3D>();
|
||
private readonly List<Card3D> _returningCards = new List<Card3D>();
|
||
private readonly List<Card3D> _visualHandCards = new List<Card3D>();
|
||
private readonly HashSet<Card3D> _pendingDrawCards =
|
||
new HashSet<Card3D>();
|
||
private readonly List<HandReturnRequest> _pendingHandReturns =
|
||
new List<HandReturnRequest>();
|
||
private readonly Dictionary<Card3D, HandReturnTarget> _handReturnTargets =
|
||
new Dictionary<Card3D, HandReturnTarget>();
|
||
private bool _handLayoutOverride;
|
||
private int _visualHandPlayer = -1;
|
||
private int _drawIndex;
|
||
private bool _drawActive;
|
||
private float _drawDuration;
|
||
private float _drawArc;
|
||
private Vector3 _drawSelfSource;
|
||
private Vector3 _drawOpponentSource;
|
||
private Vector3 _drawScale;
|
||
private float _drawNextAt;
|
||
|
||
private PlaymatLayoutConfig _config;
|
||
private PlaymatLayout _layout;
|
||
private float _handViewportX;
|
||
private MatchView _view;
|
||
private MatchView _previousView;
|
||
private Dictionary<int, SourcePose> _sourceByInstance;
|
||
private int _selfPlayer;
|
||
private bool _hasRendered;
|
||
private bool _built;
|
||
|
||
private Transform _slotsRoot;
|
||
private Transform _cardsRoot;
|
||
private Transform _pilesRoot;
|
||
private Transform _zonesRoot;
|
||
private Camera _camera;
|
||
private InputLockService _inputLock;
|
||
private AnimationConfig _animationConfig;
|
||
private float _boardLockReleaseAt = -1f;
|
||
|
||
private BoardZoneView _guardianZone;
|
||
private BoardZoneView _dropIndicator;
|
||
private ArrowDirector _arrowDirector;
|
||
private Pile3D _deckSelf;
|
||
private Pile3D _deckOpponent;
|
||
private Pile3D _dropSelf;
|
||
private Pile3D _dropOpponent;
|
||
|
||
private enum PointerMode
|
||
{
|
||
None,
|
||
Card,
|
||
Slot,
|
||
Pile,
|
||
}
|
||
|
||
private PointerMode _mode;
|
||
private Card3D _pressedCard;
|
||
private BoardCardRef _pressedCardRef;
|
||
private BoardSlotView _pressedSlot;
|
||
private Pile3D _pressedPile;
|
||
private Vector2 _pressPosition;
|
||
private bool _dragging;
|
||
private bool _handTargeting;
|
||
private BoardDropTarget? _dragTarget;
|
||
private Vector3 _dragPointerOffset;
|
||
private Card3D _hoveredHandCard;
|
||
private Card3D _dropPendingCard;
|
||
|
||
public PlaymatLayoutConfig Config => _config;
|
||
public PlaymatLayout Layout => _layout;
|
||
public int SelfPlayer => _selfPlayer;
|
||
public bool AttackArrowVisible =>
|
||
_arrowDirector != null && _arrowDirector.AttackVisible;
|
||
public CardMoveAnimator MoveAnimator => _moveAnimator;
|
||
public CameraDirector CameraDirector => _cameraDirector;
|
||
public bool IsHandDrawActive => _drawActive;
|
||
public Card3D HoveredHandCard => _hoveredHandCard;
|
||
public int ReturningCardCount => _returningCards.Count;
|
||
public IReadOnlyList<Card3D> ReturningCards => _returningCards;
|
||
public bool StagedReturnHandActive =>
|
||
_handLayoutOverride && _returningCards.Count > 0;
|
||
public IReadOnlyList<Card3D> StagedReturnCards => _visualHandCards;
|
||
public bool StagedDrawHandActive =>
|
||
_handLayoutOverride && _pendingDrawCards.Count > 0;
|
||
public IReadOnlyList<Card3D> StagedDrawCards => _visualHandCards;
|
||
public BoardArtProvider ArtProvider { get; set; }
|
||
|
||
public bool TryGetHandReturnTarget(
|
||
Card3D card,
|
||
out Vector3 position,
|
||
out Quaternion rotation)
|
||
{
|
||
if (card != null
|
||
&& _handReturnTargets.TryGetValue(card, out var target))
|
||
{
|
||
position = target.Position;
|
||
rotation = target.BaseRotation * Card3D.FaceDownFlip;
|
||
return true;
|
||
}
|
||
|
||
position = default;
|
||
rotation = default;
|
||
return false;
|
||
}
|
||
|
||
public Vector3 TriggerCameraPoint(int player)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
|
||
var position = _layout.TriggerPosition(localSeat, 0, 1);
|
||
return position + (Vector3.up * 0.5f);
|
||
}
|
||
|
||
public void SetCameraIntro()
|
||
{
|
||
EnsureCamera();
|
||
_cameraDirector?.SetImmediate(
|
||
_config.introCameraPosition,
|
||
_config.introCameraTarget,
|
||
_config.introCameraFov);
|
||
}
|
||
|
||
public void MoveCameraToMain(float duration)
|
||
{
|
||
_cameraDirector?.MoveTo(
|
||
_config.cameraPosition,
|
||
_config.cameraTarget,
|
||
_config.cameraFov,
|
||
duration);
|
||
}
|
||
|
||
public void AnimateHandDraw(float duration)
|
||
{
|
||
_handVisible = true;
|
||
_opponentHandVisible = true;
|
||
// Сначала фиксируем актуальные позы руки под текущую камеру,
|
||
// чтобы первая пара не летела к устаревшей цели.
|
||
UpdateHandView();
|
||
_drawActive = true;
|
||
_drawIndex = 0;
|
||
_drawDuration = duration;
|
||
_drawArc = _animationConfig != null
|
||
? _animationConfig.arcHeight
|
||
: 0.45f;
|
||
var selfDraw = GetDrawSource(_selfPlayer);
|
||
var opponentDraw = GetDrawSource(1 - _selfPlayer);
|
||
_drawScale = selfDraw.Scale;
|
||
_drawSelfSource = selfDraw.Position;
|
||
_drawOpponentSource = opponentDraw.Position;
|
||
|
||
_drawSelfCards.Clear();
|
||
_drawSelfCards.AddRange(
|
||
_handBindings
|
||
.Select(binding => binding.Card)
|
||
.Where(card => card != null));
|
||
_drawOpponentCards.Clear();
|
||
_drawOpponentCards.AddRange(
|
||
_cards.Values
|
||
.Where(card => card != null
|
||
&& card.Ref.Zone == BoardZone.Hand
|
||
&& card.Ref.Player != _selfPlayer)
|
||
.OrderBy(card => card.Ref.Index));
|
||
|
||
foreach (var card in _drawSelfCards)
|
||
{
|
||
card.SetVisible(false);
|
||
card.HoldAtSource(
|
||
_drawSelfSource,
|
||
card.FaceDownRotation,
|
||
_drawScale);
|
||
}
|
||
|
||
foreach (var card in _drawOpponentCards)
|
||
{
|
||
card.SetVisible(false);
|
||
card.HoldAtSource(
|
||
_drawOpponentSource,
|
||
card.FaceDownRotation,
|
||
_drawScale);
|
||
}
|
||
|
||
_drawNextAt = Time.unscaledTime;
|
||
}
|
||
|
||
private void TickHandDraw()
|
||
{
|
||
if (!_drawActive || _moveAnimator == null)
|
||
return;
|
||
|
||
if (_moveAnimator.IsBusy
|
||
|| Time.unscaledTime < _drawNextAt)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var count = Mathf.Max(_drawSelfCards.Count, _drawOpponentCards.Count);
|
||
if (_drawIndex >= count)
|
||
{
|
||
_drawActive = false;
|
||
return;
|
||
}
|
||
|
||
if (_drawIndex < _drawSelfCards.Count)
|
||
{
|
||
var card = _drawSelfCards[_drawIndex];
|
||
if (card != null)
|
||
{
|
||
var from = _drawSelfSource
|
||
+ (Vector3.up * (_drawIndex * 0.002f));
|
||
card.SetVisible(true);
|
||
_moveAnimator.EnqueueNow(
|
||
card,
|
||
from,
|
||
card.FaceDownRotation,
|
||
_drawScale,
|
||
_drawDuration,
|
||
_drawArc);
|
||
}
|
||
}
|
||
|
||
if (_drawIndex < _drawOpponentCards.Count)
|
||
{
|
||
var card = _drawOpponentCards[_drawIndex];
|
||
if (card != null)
|
||
{
|
||
var from = _drawOpponentSource
|
||
+ (Vector3.up * (_drawIndex * 0.002f));
|
||
card.SetVisible(true);
|
||
_moveAnimator.EnqueueNow(
|
||
card,
|
||
from,
|
||
card.FaceDownRotation,
|
||
_drawScale,
|
||
_drawDuration,
|
||
_drawArc);
|
||
}
|
||
}
|
||
|
||
_drawIndex++;
|
||
_drawNextAt = Time.unscaledTime + 0.05f;
|
||
}
|
||
|
||
private void ProcessPendingHandReturns(MatchView view)
|
||
{
|
||
if (_pendingHandReturns.Count == 0)
|
||
return;
|
||
|
||
if (_previousView == null || view == null)
|
||
{
|
||
_pendingHandReturns.Clear();
|
||
return;
|
||
}
|
||
|
||
var requests = _pendingHandReturns.ToArray();
|
||
_pendingHandReturns.Clear();
|
||
|
||
foreach (var request in requests)
|
||
{
|
||
var oldPlayer = _previousView.Players.FirstOrDefault(
|
||
player => player.Index == request.Player);
|
||
var newPlayer = view.Players.FirstOrDefault(
|
||
player => player.Index == request.Player);
|
||
if (oldPlayer == null || newPlayer == null)
|
||
continue;
|
||
|
||
var candidates = new List<Card3D>();
|
||
if (request.Player == _selfPlayer)
|
||
{
|
||
var newIds = new HashSet<int>(
|
||
(newPlayer.Hand ?? new List<CardView>())
|
||
.Select(card => card.InstanceId));
|
||
foreach (var pair in _cards)
|
||
{
|
||
var card = pair.Value;
|
||
if (card == null
|
||
|| card.Ref.Zone != BoardZone.Hand
|
||
|| card.Ref.Player != request.Player
|
||
|| newIds.Contains(card.Ref.InstanceId))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(request.PrintingId)
|
||
&& !string.Equals(
|
||
card.Ref.PrintingId,
|
||
request.PrintingId,
|
||
StringComparison.Ordinal))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
candidates.Add(card);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (var pair in _cards)
|
||
{
|
||
var card = pair.Value;
|
||
if (card != null
|
||
&& card.Ref.Zone == BoardZone.Hand
|
||
&& card.Ref.Player == request.Player)
|
||
{
|
||
candidates.Add(card);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (candidates.Count == 0)
|
||
continue;
|
||
|
||
// Своя рука — справа налево, это убывание индекса.
|
||
// Для противника тот же порядок индексов зеркален.
|
||
candidates.Sort((a, b) => b.Ref.Index.CompareTo(a.Ref.Index));
|
||
var take = Mathf.Min(request.Count, candidates.Count);
|
||
for (var i = 0; i < take; i++)
|
||
StartHandReturn(candidates[i], request.Player);
|
||
}
|
||
}
|
||
|
||
private void BuildVisualHand(MatchView view)
|
||
{
|
||
if (_handLayoutOverride
|
||
|| _previousView == null
|
||
|| view == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var returningPlayer = _returningCards.Count > 0
|
||
? _returningCards[0].Ref.Player
|
||
: -1;
|
||
var player = returningPlayer >= 0
|
||
? returningPlayer
|
||
: (_pendingDrawCards.Count > 0 ? _selfPlayer : -1);
|
||
if (player < 0)
|
||
return;
|
||
|
||
_visualHandCards.Clear();
|
||
_visualHandPlayer = player;
|
||
|
||
if (player != _selfPlayer)
|
||
{
|
||
foreach (var pair in _cards)
|
||
{
|
||
var card = pair.Value;
|
||
if (card != null
|
||
&& card.Ref.Zone == BoardZone.Hand
|
||
&& card.Ref.Player == player)
|
||
{
|
||
_visualHandCards.Add(card);
|
||
}
|
||
}
|
||
|
||
foreach (var card in _returningCards)
|
||
{
|
||
if (card != null
|
||
&& card.Ref.Zone == BoardZone.Hand
|
||
&& card.Ref.Player == player)
|
||
{
|
||
_visualHandCards.Add(card);
|
||
}
|
||
}
|
||
|
||
_visualHandCards.Sort(
|
||
(a, b) => a.Ref.Index.CompareTo(b.Ref.Index));
|
||
_handLayoutOverride =
|
||
_visualHandCards.Count > 0
|
||
|| _returningCards.Count > 0;
|
||
return;
|
||
}
|
||
|
||
var oldSelf = _previousView.Players.FirstOrDefault(
|
||
playerView => playerView.Index == player);
|
||
var newSelf = view.Players.FirstOrDefault(
|
||
playerView => playerView.Index == player);
|
||
if (oldSelf?.Hand == null || newSelf?.Hand == null)
|
||
return;
|
||
|
||
var newIds = new HashSet<int>(
|
||
newSelf.Hand.Select(card => card.InstanceId));
|
||
var returningIds = new HashSet<int>(
|
||
_returningCards
|
||
.Where(card => card != null
|
||
&& card.Ref.Zone == BoardZone.Hand
|
||
&& card.Ref.Player == player)
|
||
.Select(card => card.Ref.InstanceId));
|
||
|
||
var byInstance = new Dictionary<int, Card3D>();
|
||
foreach (var pair in _cards)
|
||
{
|
||
var card = pair.Value;
|
||
if (card != null
|
||
&& card.Ref.Zone == BoardZone.Hand
|
||
&& card.Ref.Player == player)
|
||
{
|
||
byInstance[card.Ref.InstanceId] = card;
|
||
}
|
||
}
|
||
|
||
foreach (var card in _returningCards)
|
||
{
|
||
if (card != null && card.Ref.Player == player)
|
||
byInstance[card.Ref.InstanceId] = card;
|
||
}
|
||
|
||
foreach (var oldCard in oldSelf.Hand)
|
||
{
|
||
if (oldCard == null)
|
||
continue;
|
||
|
||
var id = oldCard.InstanceId;
|
||
if (!newIds.Contains(id) && !returningIds.Contains(id))
|
||
continue;
|
||
|
||
if (byInstance.TryGetValue(id, out var card) && card != null)
|
||
_visualHandCards.Add(card);
|
||
}
|
||
|
||
_handLayoutOverride =
|
||
_visualHandCards.Count > 0
|
||
|| _returningCards.Count > 0
|
||
|| _pendingDrawCards.Count > 0;
|
||
}
|
||
|
||
private void BeginHandDrawArc(Card3D card)
|
||
{
|
||
if (card == null)
|
||
return;
|
||
|
||
card.SetVisible(true);
|
||
var index = _visualHandCards.Count;
|
||
var count = index + 1;
|
||
GetHandViewPose(
|
||
index,
|
||
count,
|
||
out var position,
|
||
out var rotation,
|
||
out var scale);
|
||
card.SetPose(
|
||
position,
|
||
rotation,
|
||
true,
|
||
false,
|
||
false,
|
||
false,
|
||
scale * Mathf.Max(0.05f, _config.cardScale));
|
||
}
|
||
|
||
private void OnHandDrawCompleted(Card3D card)
|
||
{
|
||
if (card == null)
|
||
return;
|
||
|
||
_pendingDrawCards.Remove(card);
|
||
if (!_visualHandCards.Contains(card))
|
||
_visualHandCards.Add(card);
|
||
|
||
if (_returningCards.Count == 0
|
||
&& _pendingDrawCards.Count == 0)
|
||
{
|
||
_handLayoutOverride = false;
|
||
_visualHandPlayer = -1;
|
||
}
|
||
|
||
UpdateHandView();
|
||
}
|
||
|
||
private void StartHandReturn(Card3D card, int player)
|
||
{
|
||
if (card == null || _moveAnimator == null)
|
||
return;
|
||
|
||
var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
|
||
var deck = player == _selfPlayer ? _deckSelf : _deckOpponent;
|
||
var deckPosition = deck != null
|
||
? deck.transform.position
|
||
: _layout.DeckPosition(localSeat);
|
||
var target = deckPosition - (Vector3.up * 0.04f);
|
||
var baseRotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat),
|
||
0f);
|
||
var fromPosition = card.transform.position;
|
||
var fromRotation = card.transform.rotation;
|
||
var fromScale = card.transform.localScale;
|
||
|
||
card.SetHighlight(null);
|
||
DetachCard(card);
|
||
_handReturnTargets[card] = new HandReturnTarget(
|
||
target,
|
||
baseRotation,
|
||
_config.cardScale);
|
||
_returningCards.Add(card);
|
||
|
||
_moveAnimator.Enqueue(
|
||
card,
|
||
fromPosition,
|
||
fromRotation,
|
||
fromScale,
|
||
DrawDuration,
|
||
_animationConfig != null
|
||
? _animationConfig.arcHeight
|
||
: 0.45f,
|
||
sequential: true,
|
||
onCompleted: OnHandReturnCompleted,
|
||
onStart: BeginHandReturnArc,
|
||
fromCurrent: true);
|
||
}
|
||
|
||
private void BeginHandReturnArc(Card3D card)
|
||
{
|
||
if (card == null
|
||
|| !_handReturnTargets.TryGetValue(card, out var target))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_handReturnTargets.Remove(card);
|
||
card.SetPose(
|
||
target.Position,
|
||
target.BaseRotation,
|
||
false,
|
||
false,
|
||
false,
|
||
false,
|
||
target.Scale);
|
||
}
|
||
|
||
private void DetachCard(Card3D card)
|
||
{
|
||
string key = null;
|
||
foreach (var pair in _cards)
|
||
{
|
||
if (pair.Value == card)
|
||
{
|
||
key = pair.Key;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (key != null)
|
||
_cards.Remove(key);
|
||
}
|
||
|
||
private void OnHandReturnCompleted(Card3D card)
|
||
{
|
||
if (card == null)
|
||
return;
|
||
|
||
_handReturnTargets.Remove(card);
|
||
_returningCards.Remove(card);
|
||
_visualHandCards.Remove(card);
|
||
if (_returningCards.Count == 0
|
||
&& _pendingDrawCards.Count == 0)
|
||
{
|
||
_handLayoutOverride = false;
|
||
_visualHandPlayer = -1;
|
||
}
|
||
|
||
ReleaseCard(card);
|
||
UpdateHandView();
|
||
}
|
||
|
||
public void SetTriggerZoneVisible(int player, bool visible)
|
||
{
|
||
if (player is < 0 or > 1)
|
||
return;
|
||
|
||
_triggerZoneVisible[player] = visible;
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card == null
|
||
|| card.Ref.Zone != BoardZone.Trigger
|
||
|| card.Ref.Player != player)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
card.SetVisible(visible);
|
||
if (visible)
|
||
card.Snap();
|
||
}
|
||
}
|
||
|
||
public void SetTriggerZoneMessage(int player, string text)
|
||
{
|
||
if (player is < 0 or > 1)
|
||
return;
|
||
|
||
var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
|
||
_triggerZones[localSeat]?.SetLabel(text ?? string.Empty);
|
||
}
|
||
|
||
public void RevealTriggerCard(int player, float duration)
|
||
{
|
||
if (player is < 0 or > 1)
|
||
return;
|
||
|
||
_pendingTriggerRevealPlayer = player;
|
||
_pendingTriggerRevealDuration = Mathf.Max(0.01f, duration);
|
||
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card == null
|
||
|| card.Ref.Zone != BoardZone.Trigger
|
||
|| card.Ref.Player != player)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
card.SetVisible(true);
|
||
card.Reveal(
|
||
_pendingTriggerRevealDuration,
|
||
player == _selfPlayer ? 1 : -1);
|
||
_pendingTriggerRevealPlayer = -1;
|
||
}
|
||
}
|
||
|
||
public bool TryGetCardScreenPosition(BoardCardRef reference, out Vector2 screen)
|
||
{
|
||
screen = default;
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
if (_camera == null)
|
||
return false;
|
||
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card == null || !card.Ref.Equals(reference))
|
||
continue;
|
||
|
||
var point = _camera.WorldToScreenPoint(card.transform.position);
|
||
if (point.z <= 0f)
|
||
return false;
|
||
|
||
screen = new Vector2(point.x, point.y);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
public void SetHandViewportX(float value)
|
||
{
|
||
_handViewportX = Mathf.Clamp(value, 0.05f, 0.95f);
|
||
}
|
||
|
||
public void SetHandVisible(bool visible)
|
||
{
|
||
_handVisible = visible;
|
||
if (!visible)
|
||
SetHoveredHandCard(null);
|
||
foreach (var binding in _handBindings)
|
||
{
|
||
if (binding.Card != null)
|
||
binding.Card.SetVisible(visible);
|
||
}
|
||
}
|
||
|
||
public void SetOpponentHandVisible(bool visible)
|
||
{
|
||
_opponentHandVisible = visible;
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card != null
|
||
&& card.Ref.Zone == BoardZone.Hand
|
||
&& card.Ref.Player != _selfPlayer)
|
||
{
|
||
card.SetVisible(visible);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Вернуть карту, удерживаемую после drag-а, в руку. Вызывается,
|
||
/// если drop не превратился в интент или сервер его отклонил.
|
||
/// </summary>
|
||
public void ReturnDroppedCard()
|
||
{
|
||
var card = _dropPendingCard;
|
||
_dropPendingCard = null;
|
||
if (card == null)
|
||
return;
|
||
|
||
card.CancelHold();
|
||
UpdateHandView();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Поставить в очередь возврат карт из руки в колоду. Карты уходят
|
||
/// по дуге под колоду с переворотом рубашкой вверх; следующая
|
||
/// стартует после завершения предыдущей.
|
||
/// </summary>
|
||
public void QueueHandReturnToDeck(
|
||
int player,
|
||
int count,
|
||
string printingId = null)
|
||
{
|
||
if (player is < 0 or > 1 || count <= 0)
|
||
return;
|
||
|
||
_pendingHandReturns.Add(new HandReturnRequest(
|
||
player,
|
||
count,
|
||
printingId));
|
||
}
|
||
|
||
public void ConfigureRuntime(
|
||
AnimationConfig animationConfig,
|
||
InputLockService inputLock)
|
||
{
|
||
_animationConfig = animationConfig;
|
||
_inputLock = inputLock;
|
||
_arrowDirector?.SetConfig(
|
||
_animationConfig,
|
||
ArrowConfig.LoadOrDefault());
|
||
_moveAnimator ??= new CardMoveAnimator(
|
||
_inputLock,
|
||
_animationConfig);
|
||
_moveAnimator.SetConfig(_animationConfig);
|
||
|
||
if (_animationConfig == null)
|
||
return;
|
||
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
card.SetAnimationSpeeds(
|
||
_animationConfig.cardMoveSpeed,
|
||
_animationConfig.cardRotateSpeed);
|
||
}
|
||
}
|
||
|
||
public event Action<BoardCardRef> CardClicked;
|
||
public event Action<BoardCardRef> CardRightClicked;
|
||
public event Action<BoardCardRef> SlotClicked;
|
||
public event Action<BoardCardRef, BoardCardRef> CardDropped;
|
||
public event Action<BoardCardRef> PileClicked;
|
||
public event Action<BoardCardRef> PileRightClicked;
|
||
|
||
public static GameBoard3D Create(Transform parent, PlaymatLayoutConfig config = null)
|
||
{
|
||
if (_instance != null)
|
||
{
|
||
var previous = _instance.gameObject;
|
||
if (Application.isPlaying)
|
||
Destroy(previous);
|
||
else
|
||
DestroyImmediate(previous);
|
||
}
|
||
|
||
var go = new GameObject(RootName);
|
||
if (parent != null)
|
||
go.transform.SetParent(parent, false);
|
||
|
||
var board = go.AddComponent<GameBoard3D>();
|
||
board.Initialize(config);
|
||
_instance = board;
|
||
return board;
|
||
}
|
||
|
||
public void Initialize(PlaymatLayoutConfig config)
|
||
{
|
||
if (_built)
|
||
return;
|
||
|
||
_config = config != null ? config : PlaymatLayoutConfig.Default;
|
||
_layout = new PlaymatLayout(_config);
|
||
_handViewportX = _config.handViewportX;
|
||
_selfPlayer = 0;
|
||
_built = true;
|
||
|
||
BuildTable();
|
||
BuildSlots();
|
||
BuildZones();
|
||
BuildPiles();
|
||
BuildDropIndicator();
|
||
BuildAttackArrow();
|
||
|
||
_cardsRoot = new GameObject("Cards").transform;
|
||
_cardsRoot.SetParent(transform, false);
|
||
|
||
EnsureCamera();
|
||
_cameraDirector = new CameraDirector(_camera);
|
||
SetVisible(false);
|
||
}
|
||
|
||
public void SetSelfPlayer(int player)
|
||
{
|
||
player = player == 0 ? 0 : 1;
|
||
if (_selfPlayer == player)
|
||
return;
|
||
|
||
_selfPlayer = player;
|
||
foreach (var slot in _slots)
|
||
slot.SetPlayer(LocalToPlayer(slot.LocalSeat));
|
||
|
||
_deckSelf.SetPlayer(LocalToPlayer(0));
|
||
_dropSelf.SetPlayer(LocalToPlayer(0));
|
||
_deckOpponent.SetPlayer(LocalToPlayer(1));
|
||
_dropOpponent.SetPlayer(LocalToPlayer(1));
|
||
_hasRendered = false;
|
||
}
|
||
|
||
public void SetVisible(bool visible)
|
||
{
|
||
if (!visible)
|
||
{
|
||
CancelPointer();
|
||
_hasRendered = false;
|
||
_moveAnimator?.CancelAll(snapCards: false);
|
||
_handBindings.Clear();
|
||
_arrowDirector?.HideImmediate();
|
||
ReleaseBoardLock();
|
||
}
|
||
else
|
||
{
|
||
EnsureCamera();
|
||
}
|
||
|
||
gameObject.SetActive(visible);
|
||
}
|
||
|
||
public void Render(MatchView view, BoardRenderState state, bool animateMoves = true)
|
||
{
|
||
if (!_built)
|
||
return;
|
||
|
||
if (view == null)
|
||
{
|
||
ClearCards();
|
||
_pendingHandReturns.Clear();
|
||
_view = null;
|
||
_previousView = null;
|
||
_sourceByInstance = null;
|
||
return;
|
||
}
|
||
|
||
SetSelfPlayer(view.You);
|
||
|
||
// Запоминаем старые позиции карт по InstanceId, пока не заменили _view.
|
||
_sourceByInstance = BuildSourceMap();
|
||
_previousView = _view;
|
||
_view = view;
|
||
|
||
state ??= new BoardRenderState();
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
|
||
_handBindings.Clear();
|
||
_opponentHandBindings.Clear();
|
||
var specs = new List<CardSpec>(40);
|
||
BuildSpecs(view, state, specs);
|
||
|
||
_moveAnimator ??= new CardMoveAnimator(
|
||
_inputLock,
|
||
_animationConfig != null
|
||
? _animationConfig
|
||
: AnimationConfig.Default);
|
||
|
||
ProcessPendingHandReturns(view);
|
||
|
||
var previousByInstance = new Dictionary<int, Card3D>();
|
||
var previousKeyByInstance = new Dictionary<int, string>();
|
||
foreach (var pair in _cards)
|
||
{
|
||
var previous = pair.Value;
|
||
if (previous == null || previous.Ref.InstanceId == 0)
|
||
continue;
|
||
|
||
previousByInstance[previous.Ref.InstanceId] = previous;
|
||
previousKeyByInstance[previous.Ref.InstanceId] = pair.Key;
|
||
}
|
||
|
||
var immediateAll = !_hasRendered;
|
||
var assignedInstances = new HashSet<int>();
|
||
var assignedCards = new HashSet<Card3D>();
|
||
var assignments = new Dictionary<string, CardAssignment>(
|
||
StringComparer.Ordinal);
|
||
|
||
foreach (var spec in specs)
|
||
{
|
||
var transferred = false;
|
||
var oldRef = default(BoardCardRef);
|
||
string oldKey = null;
|
||
var fromPosition = default(Vector3);
|
||
var fromRotation = default(Quaternion);
|
||
var fromScale = default(Vector3);
|
||
Card3D card = null;
|
||
|
||
// 1) Переиспользуем анонимные карты по стабильному ключу:
|
||
// скрытая рука противника, закрытый урон и т.п.
|
||
if (_cards.TryGetValue(spec.Key, out var existing)
|
||
&& existing != null
|
||
&& !assignedCards.Contains(existing)
|
||
&& CanReuseByKey(existing, spec))
|
||
{
|
||
card = existing;
|
||
transferred = true;
|
||
oldRef = existing.Ref;
|
||
oldKey = spec.Key;
|
||
fromPosition = card.transform.position;
|
||
fromRotation = card.transform.rotation;
|
||
fromScale = card.transform.localScale;
|
||
|
||
if (spec.Ref.InstanceId != 0)
|
||
assignedInstances.Add(spec.Ref.InstanceId);
|
||
if (existing.Ref.InstanceId != 0)
|
||
assignedInstances.Add(existing.Ref.InstanceId);
|
||
}
|
||
// 2) Перемещаем реальную карту по InstanceId.
|
||
else if (spec.Ref.InstanceId != 0
|
||
&& previousByInstance.TryGetValue(
|
||
spec.Ref.InstanceId,
|
||
out var previous)
|
||
&& assignedInstances.Add(spec.Ref.InstanceId))
|
||
{
|
||
card = previous;
|
||
transferred = true;
|
||
oldRef = previous.Ref;
|
||
previousKeyByInstance.TryGetValue(
|
||
spec.Ref.InstanceId,
|
||
out oldKey);
|
||
fromPosition = card.transform.position;
|
||
fromRotation = card.transform.rotation;
|
||
fromScale = card.transform.localScale;
|
||
}
|
||
else
|
||
{
|
||
card = RentCard();
|
||
}
|
||
|
||
assignedCards.Add(card);
|
||
assignments[spec.Key] = new CardAssignment
|
||
{
|
||
Card = card,
|
||
Transferred = transferred,
|
||
OldRef = oldRef,
|
||
OldKey = oldKey,
|
||
FromPosition = fromPosition,
|
||
FromRotation = fromRotation,
|
||
FromScale = fromScale,
|
||
};
|
||
}
|
||
|
||
foreach (var pair in _cards)
|
||
{
|
||
if (!assignedCards.Contains(pair.Value))
|
||
ReleaseCard(pair.Value);
|
||
}
|
||
|
||
_cards.Clear();
|
||
foreach (var pair in assignments)
|
||
_cards[pair.Key] = pair.Value.Card;
|
||
|
||
foreach (var spec in specs)
|
||
{
|
||
var assignment = assignments[spec.Key];
|
||
var card = assignment.Card;
|
||
var moved = !immediateAll
|
||
&& (!assignment.Transferred
|
||
|| NeedsMove(assignment.OldRef, spec.Ref));
|
||
|
||
if (moved && !assignment.Transferred)
|
||
{
|
||
var source = ResolveSourcePose(spec);
|
||
assignment.FromPosition = source.Position;
|
||
assignment.FromRotation = source.Rotation;
|
||
assignment.FromScale = source.Scale;
|
||
card.PlaceImmediate(
|
||
assignment.FromPosition,
|
||
assignment.FromRotation,
|
||
assignment.FromScale);
|
||
|
||
if (animateMoves)
|
||
card.HoldAtSource(
|
||
assignment.FromPosition,
|
||
assignment.FromRotation,
|
||
assignment.FromScale);
|
||
}
|
||
|
||
card.Bind(spec.Ref);
|
||
if (spec.Ref.Zone == BoardZone.Trigger)
|
||
card.SetVisible(_triggerZoneVisible[spec.Ref.Player]);
|
||
|
||
card.SetArt(spec.Art.Front, spec.Art.Accent);
|
||
card.SetPose(
|
||
spec.Position,
|
||
spec.BaseRotation,
|
||
spec.FaceUp,
|
||
spec.Rested,
|
||
immediateAll,
|
||
spec.DamageLayout,
|
||
spec.Scale * Mathf.Max(0.05f, _config.cardScale));
|
||
|
||
if (spec.Ref.Zone == BoardZone.Trigger
|
||
&& !_triggerZoneVisible[spec.Ref.Player])
|
||
{
|
||
card.SetVisible(false);
|
||
card.SetStats(spec.Stats, spec.StatsModified);
|
||
card.SetHighlight(spec.Highlight);
|
||
continue;
|
||
}
|
||
|
||
if (spec.IsHand)
|
||
{
|
||
_handBindings.Add(new HandCardBinding(
|
||
card,
|
||
spec.HandIndex,
|
||
spec.HandCount));
|
||
}
|
||
else if (spec.IsOpponentHand)
|
||
{
|
||
_opponentHandBindings.Add(new HandCardBinding(
|
||
card,
|
||
spec.HandIndex,
|
||
spec.HandCount));
|
||
}
|
||
|
||
if ((spec.IsHand && !_handVisible)
|
||
|| (spec.IsOpponentHand && !_opponentHandVisible))
|
||
{
|
||
card.SetVisible(false);
|
||
continue;
|
||
}
|
||
|
||
var vanguardReveal = animateMoves
|
||
&& assignment.Transferred
|
||
&& !assignment.OldRef.FaceUp
|
||
&& spec.Ref.FaceUp
|
||
&& spec.Ref.Zone == BoardZone.Vanguard;
|
||
if (vanguardReveal)
|
||
{
|
||
card.Reveal(
|
||
_animationConfig != null
|
||
? _animationConfig.vanguardRevealDuration
|
||
: 0.8f,
|
||
spec.Ref.Player == _selfPlayer ? 1 : -1);
|
||
}
|
||
|
||
var revealing = spec.Ref.Zone == BoardZone.Trigger
|
||
&& spec.Ref.Player == _pendingTriggerRevealPlayer;
|
||
if (revealing)
|
||
{
|
||
card.SetVisible(true);
|
||
card.Reveal(
|
||
_pendingTriggerRevealDuration,
|
||
spec.Ref.Player == _selfPlayer ? 1 : -1);
|
||
_pendingTriggerRevealPlayer = -1;
|
||
}
|
||
|
||
if (moved && animateMoves && !revealing && !vanguardReveal)
|
||
{
|
||
var important = spec.Ref.Zone is
|
||
BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop;
|
||
var sequentialDraw = !assignment.Transferred
|
||
&& spec.Ref.Zone == BoardZone.Hand;
|
||
var duration = important
|
||
? _animationConfig != null
|
||
? _animationConfig.importantMoveDuration
|
||
: 0.9f
|
||
: sequentialDraw
|
||
? DrawDuration
|
||
: _animationConfig != null
|
||
? _animationConfig.moveDuration
|
||
: 0.5f;
|
||
var arc = _animationConfig != null
|
||
? _animationConfig.arcHeight
|
||
: 0.45f;
|
||
var stagedDraw = sequentialDraw
|
||
&& spec.Ref.Player == _selfPlayer;
|
||
if (stagedDraw)
|
||
{
|
||
_pendingDrawCards.Add(card);
|
||
card.SetVisible(false);
|
||
}
|
||
|
||
_moveAnimator.Enqueue(
|
||
card,
|
||
assignment.FromPosition,
|
||
assignment.FromRotation,
|
||
assignment.FromScale,
|
||
duration,
|
||
arc,
|
||
sequentialDraw,
|
||
onCompleted: stagedDraw
|
||
? OnHandDrawCompleted
|
||
: null,
|
||
onStart: stagedDraw
|
||
? BeginHandDrawArc
|
||
: null,
|
||
fromCurrent: stagedDraw);
|
||
}
|
||
else if (moved && !animateMoves && !revealing)
|
||
{
|
||
Debug.LogWarning(
|
||
"[GameBoard3D] Local render moved card "
|
||
+ spec.Ref
|
||
+ " without animation.");
|
||
}
|
||
|
||
card.SetHighlight(spec.Highlight);
|
||
card.SetStats(spec.Stats, spec.StatsModified);
|
||
}
|
||
|
||
if (_dropPendingCard != null
|
||
&& _dropPendingCard.Ref.Zone != BoardZone.Hand)
|
||
{
|
||
_dropPendingCard = null;
|
||
}
|
||
|
||
UpdatePiles(view);
|
||
UpdateSlots(state);
|
||
UpdateAttackArrowState(view);
|
||
if (animateMoves && !_handLayoutOverride)
|
||
BuildVisualHand(view);
|
||
if (_handLayoutOverride)
|
||
UpdateHandView();
|
||
_hasRendered = true;
|
||
|
||
if (_inputLock != null
|
||
&& _animationConfig != null
|
||
&& _boardLockReleaseAt <= 0f
|
||
&& AnyCardAnimating())
|
||
{
|
||
_inputLock.Acquire("board");
|
||
_boardLockReleaseAt = Time.unscaledTime
|
||
+ Mathf.Max(1f, _animationConfig.cardMoveDuration * 3f)
|
||
+ _animationConfig.inputLockTail;
|
||
}
|
||
}
|
||
public void Clear()
|
||
{
|
||
_moveAnimator?.CancelAll(snapCards: false);
|
||
ClearCards();
|
||
_pendingHandReturns.Clear();
|
||
_hoveredHandCard = null;
|
||
_dropPendingCard = null;
|
||
_handBindings.Clear();
|
||
_handVisible = true;
|
||
_view = null;
|
||
_previousView = null;
|
||
_sourceByInstance = null;
|
||
_hasRendered = false;
|
||
_arrowDirector?.HideImmediate();
|
||
ReleaseBoardLock();
|
||
}
|
||
|
||
private void UpdateAttackArrowState(MatchView view)
|
||
{
|
||
if (_arrowDirector == null)
|
||
return;
|
||
|
||
if (view?.Battle == null
|
||
|| !TryResolveAttackPoints(view, out var from, out var to))
|
||
{
|
||
_arrowDirector.HideAll();
|
||
return;
|
||
}
|
||
|
||
_arrowDirector.SetAttack(from, to);
|
||
UpdateBoostArrows(view);
|
||
}
|
||
|
||
private void UpdateAttackArrowPoints()
|
||
{
|
||
if (_arrowDirector == null || _view?.Battle == null)
|
||
return;
|
||
|
||
if (TryResolveAttackPoints(_view, out var from, out var to))
|
||
_arrowDirector.SetAttack(from, to);
|
||
|
||
UpdateBoostArrows(_view);
|
||
}
|
||
|
||
private void UpdateBoostArrows(MatchView view)
|
||
{
|
||
if (_arrowDirector == null || view?.Battle == null)
|
||
return;
|
||
|
||
var battle = view.Battle;
|
||
var boosts = new List<(Vector3 From, Vector3 To)>();
|
||
if (battle.BoosterSlot >= 0)
|
||
{
|
||
var attacker = BattleCard(
|
||
view,
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot);
|
||
var booster = BattleCard(
|
||
view,
|
||
battle.AttackerPlayer,
|
||
battle.BoosterSlot);
|
||
if (attacker != null && booster != null)
|
||
{
|
||
var boosterCenter = ArrowAnchor(
|
||
BoardCardRef.Field(
|
||
battle.AttackerPlayer,
|
||
battle.BoosterSlot,
|
||
booster),
|
||
battle.AttackerPlayer,
|
||
battle.BoosterSlot);
|
||
var attackerCenter = ArrowAnchor(
|
||
BoardCardRef.Field(
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot,
|
||
attacker),
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot);
|
||
boosts.Add((
|
||
CardEdge(boosterCenter, attackerCenter),
|
||
CardEdge(attackerCenter, boosterCenter)));
|
||
}
|
||
}
|
||
|
||
_arrowDirector.SetBoosts(boosts);
|
||
}
|
||
|
||
private bool TryResolveAttackPoints(
|
||
MatchView view,
|
||
out Vector3 from,
|
||
out Vector3 to)
|
||
{
|
||
from = default;
|
||
to = default;
|
||
var battle = view?.Battle;
|
||
if (battle == null)
|
||
return false;
|
||
|
||
var attacker = BattleCard(view, battle.AttackerPlayer, battle.AttackerSlot);
|
||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||
var defender = BattleCard(view, defenderIndex, battle.TargetSlot);
|
||
if (attacker == null || defender == null)
|
||
return false;
|
||
|
||
var attackerCenter = ArrowAnchor(
|
||
BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker),
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot);
|
||
var defenderCenter = ArrowAnchor(
|
||
BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender),
|
||
defenderIndex,
|
||
battle.TargetSlot);
|
||
from = CardEdge(attackerCenter, defenderCenter);
|
||
to = CardEdge(defenderCenter, attackerCenter);
|
||
return true;
|
||
}
|
||
|
||
private Vector3 ArrowAnchor(BoardCardRef reference, int player, int slot)
|
||
{
|
||
var card = FindCard(reference);
|
||
if (card != null)
|
||
return card.transform.localPosition + (Vector3.up * 0.2f);
|
||
|
||
return _layout.CirclePositionForPlayer(player, _selfPlayer, slot)
|
||
+ (Vector3.up * 0.2f);
|
||
}
|
||
|
||
private Vector3 CardEdge(Vector3 center, Vector3 toward)
|
||
{
|
||
var direction = toward - center;
|
||
direction.y = 0f;
|
||
if (direction.sqrMagnitude < 0.0001f)
|
||
return center;
|
||
|
||
direction.Normalize();
|
||
var radius = Mathf.Max(
|
||
_config.cardWidth,
|
||
_config.cardHeight) * 0.5f * _config.cardScale;
|
||
return center + (direction * radius);
|
||
}
|
||
|
||
private CardView BattleCard(MatchView view, int playerIndex, int slot)
|
||
{
|
||
var player = view?.Players.FirstOrDefault(item => item.Index == playerIndex);
|
||
if (player == null)
|
||
return null;
|
||
|
||
if (slot < 0)
|
||
return player.Vanguard;
|
||
|
||
return player.RearGuards != null
|
||
&& slot < player.RearGuards.Length
|
||
? player.RearGuards[slot]
|
||
: null;
|
||
}
|
||
|
||
private Card3D FindCard(BoardCardRef reference)
|
||
{
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card != null && card.Ref.Equals(reference))
|
||
return card;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void BuildTable()
|
||
{
|
||
var width = (_config.boardHalfWidth * 2f) + 2.2f;
|
||
var depth = (_config.boardHalfLength * 2f) + 1.7f;
|
||
CreateBox(
|
||
"Table",
|
||
new Vector3(0f, -0.06f, 0f),
|
||
new Vector3(width, 0.12f, depth),
|
||
_config.tableColor);
|
||
|
||
var matWidth = (_config.boardHalfWidth * 2f) - 0.45f;
|
||
var matDepth = _config.boardHalfLength - 0.35f;
|
||
|
||
CreateBox(
|
||
"Mat Self",
|
||
new Vector3(0f, 0.002f, -(_config.boardHalfLength * 0.5f) - 0.05f),
|
||
new Vector3(matWidth, 0.008f, matDepth),
|
||
_config.matSelfColor);
|
||
CreateBox(
|
||
"Mat Opponent",
|
||
new Vector3(0f, 0.002f, (_config.boardHalfLength * 0.5f) + 0.05f),
|
||
new Vector3(matWidth, 0.008f, matDepth),
|
||
_config.matOpponentColor);
|
||
|
||
var divider = BoardMaterials.CreateUnlit(
|
||
null,
|
||
new Color(0.34f, 0.24f, 0.10f));
|
||
CreateBox(
|
||
"Center Line",
|
||
new Vector3(0f, 0.011f, 0f),
|
||
new Vector3(width - 0.3f, 0.012f, 0.055f),
|
||
divider);
|
||
|
||
var railMaterial = BoardMaterials.CreateUnlit(
|
||
null,
|
||
new Color(0.40f, 0.29f, 0.11f));
|
||
CreateBox(
|
||
"Rail Left",
|
||
new Vector3(-(_config.boardHalfWidth + 1.02f), 0.04f, 0f),
|
||
new Vector3(0.07f, 0.08f, depth),
|
||
railMaterial);
|
||
CreateBox(
|
||
"Rail Right",
|
||
new Vector3(_config.boardHalfWidth + 1.02f, 0.04f, 0f),
|
||
new Vector3(0.07f, 0.08f, depth),
|
||
railMaterial);
|
||
CreateBox(
|
||
"Rail Near",
|
||
new Vector3(0f, 0.04f, -(_config.boardHalfLength + 0.79f)),
|
||
new Vector3(width, 0.08f, 0.07f),
|
||
railMaterial);
|
||
CreateBox(
|
||
"Rail Far",
|
||
new Vector3(0f, 0.04f, _config.boardHalfLength + 0.79f),
|
||
new Vector3(width, 0.08f, 0.07f),
|
||
railMaterial);
|
||
}
|
||
|
||
private void BuildSlots()
|
||
{
|
||
_slotsRoot = new GameObject("Slots").transform;
|
||
_slotsRoot.SetParent(transform, false);
|
||
|
||
for (var localSeat = 0; localSeat < 2; localSeat++)
|
||
{
|
||
for (var slot = PlaymatLayout.VanguardSlot;
|
||
slot < PlaymatLayout.RearGuardCount;
|
||
slot++)
|
||
{
|
||
var view = BoardSlotView.Create(
|
||
_slotsRoot,
|
||
_config,
|
||
localSeat,
|
||
LocalToPlayer(localSeat),
|
||
slot,
|
||
_layout);
|
||
_slots.Add(view);
|
||
_slotByKey[view.Key] = view;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void BuildZones()
|
||
{
|
||
_zonesRoot = new GameObject("Zones").transform;
|
||
_zonesRoot.SetParent(transform, false);
|
||
|
||
var guardianDepth = (_config.guardianRowGap * 2f) + 0.8f;
|
||
_guardianZone = BoardZoneView.Create(
|
||
_zonesRoot,
|
||
"Guardian Zone",
|
||
new Vector3(0f, 0.012f, 0f),
|
||
_config.guardianMaxWidth + 1.4f,
|
||
guardianDepth,
|
||
Mathf.Max(0.022f, _config.cardWidth * 0.04f),
|
||
_config.guardianColor,
|
||
null,
|
||
0f,
|
||
-(guardianDepth * 0.5f) - 0.2f,
|
||
new Color(0.72f, 0.92f, 1f, 0.75f),
|
||
_config.highlightColor);
|
||
|
||
for (var localSeat = 0; localSeat < 2; localSeat++)
|
||
{
|
||
var zSign = _layout.ZSign(localSeat);
|
||
var sideSign = localSeat == 0 ? -1f : 1f;
|
||
var zone = BoardZoneView.Create(
|
||
_zonesRoot,
|
||
"Damage Zone " + localSeat,
|
||
new Vector3(
|
||
sideSign * (_config.damageX + 0.02f),
|
||
0.011f,
|
||
zSign * (_config.frontRowZ + 1.3f)),
|
||
1.15f,
|
||
2.6f,
|
||
Mathf.Max(0.018f, _config.cardWidth * 0.03f),
|
||
_config.damageColor,
|
||
null,
|
||
0f,
|
||
-zSign * 1.32f,
|
||
new Color(1f, 0.78f, 0.76f, 0.75f),
|
||
_config.highlightColor);
|
||
_damageZones[localSeat] = zone;
|
||
|
||
_damageCounts[localSeat] = BoardText.Create(
|
||
_zonesRoot,
|
||
"0/6",
|
||
0.07f,
|
||
new Color(1f, 0.62f, 0.62f, 0.92f),
|
||
new Vector3(
|
||
sideSign * (_config.damageX + 0.02f),
|
||
0.02f,
|
||
zSign * (_config.frontRowZ - 0.25f)));
|
||
|
||
var triggerPosition = _layout.TriggerPosition(localSeat, 0, 1);
|
||
_triggerZones[localSeat] = BoardZoneView.Create(
|
||
_zonesRoot,
|
||
"Trigger Zone " + localSeat,
|
||
new Vector3(triggerPosition.x, 0.011f, triggerPosition.z),
|
||
1.25f,
|
||
1.05f,
|
||
Mathf.Max(0.016f, _config.cardWidth * 0.025f),
|
||
new Color(0.30f, 0.18f, 0.52f),
|
||
_config.showZoneLabels ? "TRIGGER" : string.Empty,
|
||
0.04f,
|
||
zSign * 0.72f,
|
||
new Color(0.82f, 0.76f, 1f, 0.75f),
|
||
_config.highlightColor);
|
||
}
|
||
}
|
||
|
||
private void BuildPiles()
|
||
{
|
||
_pilesRoot = new GameObject("Piles").transform;
|
||
_pilesRoot.SetParent(transform, false);
|
||
|
||
_deckSelf = CreatePile(
|
||
BoardZone.Deck, 0, 0,
|
||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||
new Color(0.20f, 0.34f, 0.60f));
|
||
_deckOpponent = CreatePile(
|
||
BoardZone.Deck, 1, 1,
|
||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||
new Color(0.60f, 0.26f, 0.26f));
|
||
_dropSelf = CreatePile(
|
||
BoardZone.Drop, 0, 0,
|
||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||
new Color(0.30f, 0.30f, 0.40f));
|
||
_dropOpponent = CreatePile(
|
||
BoardZone.Drop, 1, 1,
|
||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||
new Color(0.40f, 0.28f, 0.28f));
|
||
}
|
||
|
||
private Pile3D CreatePile(
|
||
BoardZone zone,
|
||
int player,
|
||
int localSeat,
|
||
string title,
|
||
Color accent)
|
||
{
|
||
var position = zone switch
|
||
{
|
||
BoardZone.Deck => _layout.DeckPosition(localSeat),
|
||
BoardZone.Drop => _layout.DropPosition(localSeat),
|
||
_ => _layout.SoulPosition(localSeat),
|
||
};
|
||
|
||
var pile = Pile3D.Create(
|
||
_pilesRoot,
|
||
_config,
|
||
zone,
|
||
player,
|
||
position,
|
||
title,
|
||
accent);
|
||
_piles.Add(pile);
|
||
return pile;
|
||
}
|
||
|
||
private void BuildDropIndicator()
|
||
{
|
||
_dropIndicator = BoardZoneView.Create(
|
||
transform,
|
||
"Drop Indicator",
|
||
new Vector3(0f, 0.014f, 0f),
|
||
_config.cardWidth * 1.4f,
|
||
_config.cardHeight * 1.4f,
|
||
0.025f,
|
||
_config.highlightColor,
|
||
null,
|
||
0.1f,
|
||
0f,
|
||
Color.white,
|
||
_config.selectedColor);
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void BuildAttackArrow()
|
||
{
|
||
_arrowDirector = new ArrowDirector(
|
||
transform,
|
||
_animationConfig,
|
||
ArrowConfig.LoadOrDefault());
|
||
}
|
||
|
||
private void EnsureCamera()
|
||
{
|
||
if (_cameraDirector != null && _cameraDirector.IsActive)
|
||
return;
|
||
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
if (_camera == null)
|
||
return;
|
||
|
||
_camera.transform.position = _config.cameraPosition;
|
||
var direction = _config.cameraTarget - _config.cameraPosition;
|
||
if (direction.sqrMagnitude > 0.0001f)
|
||
_camera.transform.rotation = Quaternion.LookRotation(direction, Vector3.up);
|
||
_camera.fieldOfView = _config.cameraFov;
|
||
}
|
||
|
||
private void BuildSpecs(
|
||
MatchView view,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
foreach (var player in view.Players)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(player.Index, _selfPlayer);
|
||
AddFieldSpecs(player, localSeat, state, specs);
|
||
AddGuardianSpecs(view, player, localSeat, specs);
|
||
AddDamageSpecs(player, localSeat, state, specs);
|
||
AddTriggerSpecs(player, localSeat, specs);
|
||
}
|
||
|
||
AddHandSpecs(view, state, specs);
|
||
AddOpponentHandSpecs(view, specs);
|
||
}
|
||
|
||
private void AddFieldSpecs(
|
||
PlayerView player,
|
||
int localSeat,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
AddFieldCard(player, localSeat, PlaymatLayout.VanguardSlot, player.Vanguard, state, specs);
|
||
|
||
if (player.RearGuards == null)
|
||
return;
|
||
|
||
for (var slot = 0; slot < player.RearGuards.Length; slot++)
|
||
AddFieldCard(player, localSeat, slot, player.RearGuards[slot], state, specs);
|
||
}
|
||
|
||
private void AddFieldCard(
|
||
PlayerView player,
|
||
int localSeat,
|
||
int slot,
|
||
CardView card,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
if (card == null)
|
||
return;
|
||
|
||
var key = new BoardSlotKey(player.Index, slot);
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "F:" + player.Index + ":" + slot,
|
||
Ref = BoardCardRef.Field(player.Index, slot, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = _layout.CirclePosition(localSeat, slot),
|
||
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
|
||
FaceUp = card.FaceUp,
|
||
Rested = card.Rested,
|
||
Stats = card.FaceUp ? CardStatsText(card) : null,
|
||
StatsModified = card.Power != card.BasePower
|
||
|| card.Critical != card.BaseCritical,
|
||
Highlight = state.SelectedHighlights.Contains(key)
|
||
? _config.selectedColor
|
||
: state.Highlights.Contains(key)
|
||
? _config.highlightColor
|
||
: (Color?)null,
|
||
});
|
||
}
|
||
|
||
private static string CardStatsText(CardView card)
|
||
{
|
||
// ▲ — сила, ◆ — крит. Крит показываем только если он выше 1.
|
||
var text = "\u25B2" + card.Power;
|
||
if (card.Critical > 1)
|
||
text += " \u25C6" + card.Critical;
|
||
return text;
|
||
}
|
||
|
||
private void AddGuardianSpecs(
|
||
MatchView view,
|
||
PlayerView player,
|
||
int localSeat,
|
||
List<CardSpec> specs)
|
||
{
|
||
var guardians = player.Guardian ?? new List<CardView>();
|
||
var battleCards = view.Battle != null && view.Battle.AttackerPlayer != player.Index
|
||
? view.Battle.GuardCards ?? new List<CardView>()
|
||
: new List<CardView>();
|
||
|
||
// Guardian and battle guard lists contain the same cards after a
|
||
// guard command; render each physical instance only once.
|
||
var cards = guardians
|
||
.Concat(battleCards)
|
||
.GroupBy(card => card.InstanceId)
|
||
.Select(group => group.First())
|
||
.ToList();
|
||
var total = cards.Count;
|
||
if (total == 0)
|
||
return;
|
||
|
||
for (var i = 0; i < total; i++)
|
||
{
|
||
var card = cards[i];
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "G:" + player.Index + ":" + card.InstanceId,
|
||
Ref = BoardCardRef.Guardian(player.Index, i, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = _layout.GuardianPosition(localSeat, i, total),
|
||
BaseRotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat) + 90f,
|
||
0f),
|
||
FaceUp = true,
|
||
Rested = false,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void AddDamageSpecs(
|
||
PlayerView player,
|
||
int localSeat,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
var faceUp = player.FaceUpDamage ?? new List<CardView>();
|
||
var total = Mathf.Min(
|
||
PlaymatLayout.DamageSlots,
|
||
faceUp.Count + Mathf.Max(0, player.FaceDownDamage));
|
||
|
||
for (var i = 0; i < total; i++)
|
||
{
|
||
var isUp = i < faceUp.Count;
|
||
var card = isUp ? faceUp[i] : null;
|
||
var highlight = DamageHighlight(state, player.Index, i);
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "D:" + player.Index + ":" + i,
|
||
Ref = BoardCardRef.Damage(player.Index, i, card, isUp),
|
||
Art = isUp
|
||
? ResolveArt(card.PrintingId)
|
||
: BoardArt.Fallback(_config.damageColor),
|
||
Position = _layout.DamagePosition(localSeat, i),
|
||
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
|
||
FaceUp = isUp,
|
||
Rested = true,
|
||
DamageLayout = true,
|
||
Highlight = highlight,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void AddTriggerSpecs(
|
||
PlayerView player,
|
||
int localSeat,
|
||
List<CardSpec> specs)
|
||
{
|
||
var triggers = player.Trigger ?? new List<CardView>();
|
||
if (triggers.Count == 0)
|
||
return;
|
||
|
||
var count = Mathf.Min(3, triggers.Count);
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var card = triggers[i];
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "T:" + player.Index + ":" + card.InstanceId,
|
||
Ref = BoardCardRef.Trigger(player.Index, i, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = _layout.TriggerPosition(localSeat, i, count),
|
||
BaseRotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat)
|
||
+ (localSeat == 0 ? -90f : 90f),
|
||
0f),
|
||
FaceUp = true,
|
||
Rested = false,
|
||
});
|
||
}
|
||
}
|
||
|
||
private Color? DamageHighlight(
|
||
BoardRenderState state,
|
||
int player,
|
||
int index)
|
||
{
|
||
var damageChoice = state.DamageChoice
|
||
&& player == state.DamageChoicePlayer;
|
||
var abilityCost = state.AbilityDamageTargeting
|
||
&& player == state.AbilityDamagePlayer;
|
||
if (!damageChoice && !abilityCost)
|
||
return null;
|
||
|
||
if (abilityCost
|
||
&& (state.AbilityDamageIndex == index
|
||
|| state.AbilityDamageIndexes.Contains(index)))
|
||
{
|
||
return _config.selectedColor;
|
||
}
|
||
|
||
return _config.highlightColor;
|
||
}
|
||
|
||
private void AddHandSpecs(
|
||
MatchView view,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
var self = view.Players.FirstOrDefault(player => player.Index == view.You);
|
||
if (self?.Hand == null)
|
||
return;
|
||
|
||
var count = self.Hand.Count;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var card = self.Hand[i];
|
||
GetHandViewPose(i, count, out var position, out var rotation, out var scale);
|
||
Color? highlight = null;
|
||
if (state.SelectedHandInstanceIds.Contains(card.InstanceId))
|
||
highlight = _config.selectedColor;
|
||
else if (state.AvailableHandInstanceIds.Contains(card.InstanceId))
|
||
highlight = _config.highlightColor;
|
||
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "H:" + card.InstanceId,
|
||
Ref = BoardCardRef.Hand(self.Index, i, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = position,
|
||
BaseRotation = rotation,
|
||
FaceUp = true,
|
||
Rested = false,
|
||
Scale = scale,
|
||
IsHand = true,
|
||
HandIndex = i,
|
||
HandCount = count,
|
||
Highlight = highlight,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Рука висит в поле зрения камеры снизу по центру и всегда смотрит
|
||
/// на игрока. При отсутствии камеры используется старый настольный
|
||
/// вариант — для EditMode-проверок.
|
||
/// </summary>
|
||
private void GetHandViewPose(
|
||
int index,
|
||
int count,
|
||
out Vector3 position,
|
||
out Quaternion rotation,
|
||
out float scale)
|
||
{
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
|
||
if (_camera == null)
|
||
{
|
||
var fallback = _layout.GetHandPose(index, count);
|
||
position = fallback.Position;
|
||
rotation = Quaternion.Euler(_config.handTilt, fallback.Yaw, 0f);
|
||
scale = 1f;
|
||
return;
|
||
}
|
||
|
||
var safeCount = Mathf.Max(1, count);
|
||
var step = _config.handSpacing;
|
||
if (safeCount > 1)
|
||
step = Mathf.Min(step, _config.handViewMaxWidth / (safeCount - 1));
|
||
|
||
var offset = index - (safeCount - 1) * 0.5f;
|
||
var halfHeight = Mathf.Tan(
|
||
_camera.fieldOfView * 0.5f * Mathf.Deg2Rad) * _config.handViewDistance;
|
||
var halfWidth = halfHeight * Mathf.Max(0.1f, _camera.aspect);
|
||
// Своя рука заполняется слева направо: первая карта слева,
|
||
// следующие добавляются правее.
|
||
var x = ((_handViewportX - 0.5f) * 2f * halfWidth)
|
||
+ (offset * step);
|
||
var y = (_config.handViewportY - 0.5f) * 2f * halfHeight
|
||
+ (1f - Mathf.Abs(offset) / safeCount) * _config.handViewArc;
|
||
// Веер строится по X/Y/roll; по глубине каждая следующая карта
|
||
// чуть ближе к камере, чтобы новая карта всегда была поверх
|
||
// предыдущей и не проваливалась под неё.
|
||
var z = _config.handViewDistance - (index * 0.006f);
|
||
|
||
position = _camera.transform.TransformPoint(new Vector3(x, y, z));
|
||
var roll = -offset * _config.handViewRoll;
|
||
rotation = _camera.transform.rotation
|
||
* Quaternion.Euler(0f, 0f, roll)
|
||
* Quaternion.Euler(-90f, 0f, 0f);
|
||
scale = _config.handViewScale;
|
||
}
|
||
|
||
private Vector3 ApplyHandHover(
|
||
Card3D card,
|
||
Vector3 position,
|
||
float scale)
|
||
{
|
||
if (card == null || card != _hoveredHandCard)
|
||
return position;
|
||
|
||
var cardScale = Mathf.Max(0.05f, _config.cardScale);
|
||
var up = _camera != null
|
||
? _camera.transform.up
|
||
: Vector3.up;
|
||
return position + (up * (_config.cardHeight
|
||
* scale
|
||
* cardScale
|
||
* _config.handHoverLift));
|
||
}
|
||
|
||
private void UpdateHandView()
|
||
{
|
||
if (_handVisible
|
||
&& _handLayoutOverride
|
||
&& _visualHandPlayer == _selfPlayer)
|
||
{
|
||
var count = _visualHandCards.Count;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var card = _visualHandCards[i];
|
||
if (card == null
|
||
|| card.IsDragging
|
||
|| card.IsMoving)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
GetHandViewPose(
|
||
i,
|
||
count,
|
||
out var position,
|
||
out var rotation,
|
||
out var scale);
|
||
position = ApplyHandHover(card, position, scale);
|
||
card.SetPose(
|
||
position,
|
||
rotation,
|
||
true,
|
||
false,
|
||
false,
|
||
false,
|
||
scale * Mathf.Max(0.05f, _config.cardScale));
|
||
}
|
||
}
|
||
else if (_handVisible && _handBindings.Count > 0)
|
||
{
|
||
if (_camera == null)
|
||
{
|
||
_camera = Camera.main;
|
||
if (_camera == null)
|
||
return;
|
||
}
|
||
|
||
foreach (var binding in _handBindings)
|
||
{
|
||
if (binding.Card == null
|
||
|| binding.Card.IsDragging
|
||
|| binding.Card.IsMoving)
|
||
continue;
|
||
|
||
GetHandViewPose(
|
||
binding.Index,
|
||
binding.Count,
|
||
out var position,
|
||
out var rotation,
|
||
out var scale);
|
||
position = ApplyHandHover(
|
||
binding.Card,
|
||
position,
|
||
scale);
|
||
binding.Card.SetPose(
|
||
position,
|
||
rotation,
|
||
true,
|
||
false,
|
||
false,
|
||
false,
|
||
scale * Mathf.Max(0.05f, _config.cardScale));
|
||
}
|
||
}
|
||
|
||
if (!_opponentHandVisible)
|
||
return;
|
||
|
||
if (_handLayoutOverride
|
||
&& _visualHandPlayer != _selfPlayer)
|
||
{
|
||
var count = _visualHandCards.Count;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var card = _visualHandCards[i];
|
||
if (card == null
|
||
|| card.IsDragging
|
||
|| card.IsMoving)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
GetOpponentHandViewPose(
|
||
i,
|
||
count,
|
||
out var position,
|
||
out var rotation,
|
||
out var scale);
|
||
card.SetPose(
|
||
position,
|
||
rotation,
|
||
false,
|
||
false,
|
||
false,
|
||
false,
|
||
scale * Mathf.Max(0.05f, _config.cardScale));
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (_opponentHandBindings.Count == 0)
|
||
return;
|
||
|
||
if (_camera == null)
|
||
{
|
||
_camera = Camera.main;
|
||
if (_camera == null)
|
||
return;
|
||
}
|
||
|
||
foreach (var binding in _opponentHandBindings)
|
||
{
|
||
if (binding.Card == null
|
||
|| binding.Card.IsDragging
|
||
|| binding.Card.IsMoving)
|
||
continue;
|
||
|
||
GetOpponentHandViewPose(
|
||
binding.Index,
|
||
binding.Count,
|
||
out var position,
|
||
out var rotation,
|
||
out var scale);
|
||
binding.Card.SetPose(
|
||
position,
|
||
rotation,
|
||
false,
|
||
false,
|
||
false,
|
||
false,
|
||
scale * Mathf.Max(0.05f, _config.cardScale));
|
||
}
|
||
}
|
||
|
||
private void AddOpponentHandSpecs(MatchView view, List<CardSpec> specs)
|
||
{
|
||
if (!_config.opponentHandEnabled)
|
||
return;
|
||
|
||
var opponent = view.Players.FirstOrDefault(player => player.Index != view.You);
|
||
if (opponent == null || opponent.HandCount <= 0)
|
||
return;
|
||
|
||
var count = opponent.HandCount;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
GetOpponentHandViewPose(
|
||
i,
|
||
count,
|
||
out var position,
|
||
out var rotation,
|
||
out var scale);
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "OH:" + opponent.Index + ":" + i,
|
||
Ref = BoardCardRef.Hand(opponent.Index, i, null),
|
||
Art = BoardArt.Fallback(_config.matOpponentColor),
|
||
Position = position,
|
||
BaseRotation = rotation,
|
||
FaceUp = false,
|
||
Rested = false,
|
||
Scale = scale,
|
||
IsOpponentHand = true,
|
||
HandIndex = i,
|
||
HandCount = count,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void GetOpponentHandViewPose(
|
||
int index,
|
||
int count,
|
||
out Vector3 position,
|
||
out Quaternion rotation,
|
||
out float scale)
|
||
{
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
|
||
if (_camera == null)
|
||
{
|
||
var fallback = _layout.GetOpponentHandPose(index, count);
|
||
position = fallback.Position;
|
||
rotation = Quaternion.Euler(-90f, 0f, fallback.Yaw);
|
||
scale = _config.opponentHandScale;
|
||
return;
|
||
}
|
||
|
||
var safeCount = Mathf.Max(1, count);
|
||
var step = _config.opponentHandSpacing;
|
||
if (safeCount > 1)
|
||
{
|
||
step = Mathf.Min(
|
||
step,
|
||
_config.opponentHandViewMaxWidth / (safeCount - 1));
|
||
}
|
||
|
||
var offset = index - (safeCount - 1) * 0.5f;
|
||
var halfHeight = Mathf.Tan(
|
||
_camera.fieldOfView * 0.5f * Mathf.Deg2Rad)
|
||
* _config.opponentHandViewDistance;
|
||
var halfWidth = halfHeight * Mathf.Max(0.1f, _camera.aspect);
|
||
// Рука противника зеркальна своей: на экране заполняется
|
||
// справа налево, новая карта появляется слева.
|
||
var x = -offset * step;
|
||
var y = (_config.opponentHandViewportY - 0.5f) * 2f * halfHeight
|
||
+ (1f - Mathf.Abs(offset) / safeCount)
|
||
* _config.opponentHandViewArc;
|
||
var z = _config.opponentHandViewDistance - (index * 0.006f);
|
||
|
||
position = _camera.transform.TransformPoint(new Vector3(x, y, z));
|
||
var roll = offset * _config.opponentHandViewRoll;
|
||
rotation = _camera.transform.rotation
|
||
* Quaternion.Euler(0f, 0f, roll)
|
||
* Quaternion.Euler(-90f, 0f, 0f);
|
||
scale = _config.opponentHandViewScale;
|
||
}
|
||
|
||
private void UpdatePiles(MatchView view)
|
||
{
|
||
var self = view.Players.FirstOrDefault(player => player.Index == view.You);
|
||
var opponent = view.Players.FirstOrDefault(player => player.Index != view.You);
|
||
|
||
UpdatePileSide(self, _deckSelf, _dropSelf);
|
||
UpdatePileSide(opponent, _deckOpponent, _dropOpponent);
|
||
|
||
_damageZones[0]?.SetLabel(string.Empty);
|
||
_damageZones[1]?.SetLabel(string.Empty);
|
||
|
||
UpdateDamageCount(self, 0);
|
||
UpdateDamageCount(opponent, 1);
|
||
|
||
if (_guardianZone != null)
|
||
_guardianZone.SetLabel(string.Empty);
|
||
}
|
||
|
||
private void UpdateDamageCount(PlayerView player, int localSeat)
|
||
{
|
||
if (localSeat is < 0 or > 1)
|
||
return;
|
||
|
||
var count = player != null
|
||
? player.FaceDownDamage + (player.FaceUpDamage?.Count ?? 0)
|
||
: 0;
|
||
BoardText.SetText(_damageCounts[localSeat], count + "/6");
|
||
}
|
||
|
||
private void UpdatePileSide(
|
||
PlayerView player,
|
||
Pile3D deck,
|
||
Pile3D drop)
|
||
{
|
||
if (deck == null)
|
||
return;
|
||
|
||
if (player == null)
|
||
{
|
||
deck.SetCount(0, null, _config.showZoneLabels ? "DECK" : string.Empty, Color.white);
|
||
drop.SetCount(0, null, _config.showZoneLabels ? "DROP" : string.Empty, Color.white);
|
||
return;
|
||
}
|
||
|
||
deck.SetCount(
|
||
player.DeckCount,
|
||
null,
|
||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||
new Color(0.20f, 0.34f, 0.60f));
|
||
|
||
var topDrop = player.Drop != null && player.Drop.Count > 0
|
||
? player.Drop[player.Drop.Count - 1]
|
||
: null;
|
||
drop.SetCount(
|
||
player.Drop?.Count ?? 0,
|
||
topDrop != null
|
||
? (_config.dropZoneFaceDown
|
||
? BoardTextures.Back
|
||
: ResolveArt(topDrop.PrintingId).Front)
|
||
: null,
|
||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||
new Color(0.30f, 0.30f, 0.40f));
|
||
}
|
||
|
||
private void UpdateSlots(BoardRenderState state)
|
||
{
|
||
_activeHighlights.Clear();
|
||
foreach (var key in state.Highlights)
|
||
_activeHighlights.Add(key);
|
||
|
||
_selectedHighlights.Clear();
|
||
foreach (var key in state.SelectedHighlights)
|
||
_selectedHighlights.Add(key);
|
||
|
||
_validDropSlots.Clear();
|
||
foreach (var key in state.DropTargets)
|
||
_validDropSlots.Add(key);
|
||
_guardianDropAllowed = state.GuardianDropAllowed;
|
||
|
||
ApplySlotHighlights();
|
||
}
|
||
|
||
private void ApplySlotHighlights()
|
||
{
|
||
foreach (var slot in _slots)
|
||
{
|
||
var selected = _selectedHighlights.Contains(slot.Key);
|
||
var highlighted = selected
|
||
|| _activeHighlights.Contains(slot.Key)
|
||
|| (_dragging && _validDropSlots.Contains(slot.Key));
|
||
slot.SetHighlighted(
|
||
highlighted,
|
||
selected ? _config.selectedColor : _config.highlightColor);
|
||
}
|
||
|
||
if (_guardianZone != null)
|
||
{
|
||
_guardianZone.SetHighlighted(_dragging && _guardianDropAllowed);
|
||
}
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
if (_boardLockReleaseAt > 0f
|
||
&& (!AnyCardAnimating()
|
||
|| Time.unscaledTime >= _boardLockReleaseAt))
|
||
{
|
||
_boardLockReleaseAt = -1f;
|
||
_inputLock?.Release("board");
|
||
}
|
||
|
||
UpdateHandView();
|
||
|
||
var delta = Time.unscaledDeltaTime;
|
||
_moveAnimator?.Tick(delta);
|
||
TickHandDraw();
|
||
_cameraDirector?.Tick(delta);
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card != null)
|
||
card.Tick(delta);
|
||
}
|
||
|
||
for (var i = _returningCards.Count - 1; i >= 0; i--)
|
||
{
|
||
var card = _returningCards[i];
|
||
if (card == null || !card.gameObject.activeInHierarchy)
|
||
{
|
||
_returningCards.RemoveAt(i);
|
||
continue;
|
||
}
|
||
|
||
card.Tick(delta);
|
||
}
|
||
|
||
_arrowDirector?.Tick(delta);
|
||
UpdateAttackArrowPoints();
|
||
|
||
if (_inputLock != null && _inputLock.IsLocked)
|
||
{
|
||
if (_mode != PointerMode.None || _dragging)
|
||
CancelPointer();
|
||
SetHoveredHandCard(null);
|
||
return;
|
||
}
|
||
|
||
HandleRightClick();
|
||
HandlePointer();
|
||
}
|
||
|
||
private void HandleRightClick()
|
||
{
|
||
var mouse = Mouse.current;
|
||
if (mouse == null
|
||
|| !mouse.rightButton.wasPressedThisFrame
|
||
|| IsPointerOverUi())
|
||
{
|
||
return;
|
||
}
|
||
|
||
var ray = GetRay(mouse.position.ReadValue());
|
||
if (!TryRaycast(ray, out var card, out _, out var pile))
|
||
return;
|
||
|
||
if (card != null)
|
||
{
|
||
CardRightClicked?.Invoke(card.Ref);
|
||
return;
|
||
}
|
||
|
||
if (pile != null)
|
||
PileRightClicked?.Invoke(pile.Ref);
|
||
}
|
||
|
||
private void HandlePointer()
|
||
{
|
||
if (!_hasRendered && _view == null)
|
||
return;
|
||
|
||
if (!TryReadPointer(out var pointer, out var pressed, out var held, out var released))
|
||
{
|
||
SetHoveredHandCard(null);
|
||
return;
|
||
}
|
||
|
||
UpdateHandHover(pointer);
|
||
|
||
if (_handTargeting)
|
||
{
|
||
// Карта из руки остаётся в руке; двигается только цель.
|
||
UpdateHandTargeting(pointer);
|
||
if (released)
|
||
FinishHandTargeting(pointer);
|
||
return;
|
||
}
|
||
|
||
if (_dragging)
|
||
{
|
||
// Карта должна следовать за курсором каждый кадр, а не
|
||
// только в момент старта drag-а и на отпускании.
|
||
UpdateDrag(pointer);
|
||
if (released)
|
||
FinishDrag(pointer);
|
||
return;
|
||
}
|
||
|
||
if (pressed)
|
||
OnPointerPressed(pointer);
|
||
else if (held && _mode == PointerMode.Card)
|
||
OnPointerHeld(pointer);
|
||
else if (released)
|
||
OnPointerReleased(pointer);
|
||
}
|
||
|
||
private void UpdateHandHover(Vector2 pointer)
|
||
{
|
||
if (_handTargeting)
|
||
{
|
||
SetHoveredHandCard(_pressedCard);
|
||
return;
|
||
}
|
||
|
||
if (!_handVisible
|
||
|| _handLayoutOverride
|
||
|| _drawActive
|
||
|| _returningCards.Count > 0
|
||
|| _dragging
|
||
|| IsPointerOverUi())
|
||
{
|
||
SetHoveredHandCard(null);
|
||
return;
|
||
}
|
||
|
||
var ray = GetRay(pointer);
|
||
if (!TryRaycast(ray, out var card, out _, out _)
|
||
|| card == null
|
||
|| card.Ref.Zone != BoardZone.Hand
|
||
|| card.Ref.Player != _selfPlayer
|
||
|| card.IsMoving)
|
||
{
|
||
SetHoveredHandCard(null);
|
||
return;
|
||
}
|
||
|
||
SetHoveredHandCard(card);
|
||
}
|
||
|
||
private void SetHoveredHandCard(Card3D card)
|
||
{
|
||
if (_hoveredHandCard == card)
|
||
return;
|
||
|
||
_hoveredHandCard = card;
|
||
UpdateHandView();
|
||
}
|
||
|
||
private static bool TryReadPointer(
|
||
out Vector2 pointer,
|
||
out bool pressed,
|
||
out bool held,
|
||
out bool released)
|
||
{
|
||
pointer = default;
|
||
pressed = false;
|
||
held = false;
|
||
released = false;
|
||
|
||
var mouse = Mouse.current;
|
||
if (mouse != null)
|
||
{
|
||
pointer = mouse.position.ReadValue();
|
||
pressed = mouse.leftButton.wasPressedThisFrame;
|
||
held = mouse.leftButton.isPressed;
|
||
released = mouse.leftButton.wasReleasedThisFrame;
|
||
return true;
|
||
}
|
||
|
||
var touch = Touchscreen.current;
|
||
if (touch == null)
|
||
return false;
|
||
|
||
var primary = touch.primaryTouch;
|
||
pointer = primary.position.ReadValue();
|
||
pressed = primary.press.wasPressedThisFrame;
|
||
held = primary.press.isPressed;
|
||
released = primary.press.wasReleasedThisFrame;
|
||
return true;
|
||
}
|
||
|
||
private void OnPointerPressed(Vector2 pointer)
|
||
{
|
||
if (_dropPendingCard != null)
|
||
ReturnDroppedCard();
|
||
CancelPointer();
|
||
if (IsPointerOverUi())
|
||
return;
|
||
|
||
var ray = GetRay(pointer);
|
||
if (!TryRaycast(ray, out var card, out var slot, out var pile))
|
||
return;
|
||
|
||
_pressPosition = pointer;
|
||
if (card != null)
|
||
{
|
||
_mode = PointerMode.Card;
|
||
_pressedCard = card;
|
||
_pressedCardRef = card.Ref;
|
||
return;
|
||
}
|
||
|
||
if (slot != null)
|
||
{
|
||
_mode = PointerMode.Slot;
|
||
_pressedSlot = slot;
|
||
return;
|
||
}
|
||
|
||
if (pile != null)
|
||
{
|
||
_mode = PointerMode.Pile;
|
||
_pressedPile = pile;
|
||
}
|
||
}
|
||
|
||
private void OnPointerHeld(Vector2 pointer)
|
||
{
|
||
if (_pressedCard == null || _pressedCardRef.IsEmpty)
|
||
return;
|
||
|
||
if (_pressedCardRef.Player != _selfPlayer)
|
||
return;
|
||
|
||
if (!_pressedCardRef.IsField && !_pressedCardRef.IsHand)
|
||
return;
|
||
|
||
if (Vector2.Distance(pointer, _pressPosition)
|
||
< Mathf.Max(2f, _config.dragThresholdPixels))
|
||
return;
|
||
|
||
if (_pressedCardRef.IsHand)
|
||
{
|
||
_handTargeting = true;
|
||
SetHoveredHandCard(_pressedCard);
|
||
ApplySlotHighlights();
|
||
UpdateHandTargeting(pointer);
|
||
return;
|
||
}
|
||
|
||
_dragging = true;
|
||
_pressedCard.BeginDrag();
|
||
_dragPointerOffset = Vector3.zero;
|
||
|
||
var dragRay = GetRay(pointer);
|
||
var dragPlane = GetDragPlane(_pressedCard);
|
||
if (dragPlane.Raycast(dragRay, out var enter))
|
||
{
|
||
var point = dragRay.GetPoint(enter);
|
||
var origin = _pressedCard.transform.position;
|
||
_dragPointerOffset = new Vector3(
|
||
origin.x - point.x,
|
||
0f,
|
||
origin.z - point.z);
|
||
}
|
||
|
||
ApplySlotHighlights();
|
||
// Карта начнёт движение только со следующего кадра: в момент
|
||
// начала drag-а она остаётся в исходной позе руки.
|
||
}
|
||
|
||
private Plane GetDragPlane(Card3D card)
|
||
{
|
||
var height = (card != null ? card.DragLift : _config.dragLift)
|
||
+ (_config.cardThickness * 0.5f);
|
||
return new Plane(
|
||
Vector3.up,
|
||
new Vector3(0f, height, 0f));
|
||
}
|
||
|
||
private void OnPointerReleased(Vector2 pointer)
|
||
{
|
||
if (_handTargeting)
|
||
{
|
||
FinishHandTargeting(pointer);
|
||
return;
|
||
}
|
||
|
||
if (_dragging)
|
||
{
|
||
FinishDrag(pointer);
|
||
return;
|
||
}
|
||
|
||
if (_mode == PointerMode.Card && _pressedCard != null)
|
||
{
|
||
CardClicked?.Invoke(_pressedCardRef);
|
||
}
|
||
else if (_mode == PointerMode.Slot && _pressedSlot != null)
|
||
{
|
||
SlotClicked?.Invoke(_pressedSlot.Ref);
|
||
}
|
||
else if (_mode == PointerMode.Pile && _pressedPile != null)
|
||
{
|
||
PileClicked?.Invoke(_pressedPile.Ref);
|
||
}
|
||
|
||
CancelPointer();
|
||
}
|
||
|
||
private void UpdateHandTargeting(Vector2 pointer)
|
||
{
|
||
if (_pressedCard == null)
|
||
return;
|
||
|
||
var ray = GetRay(pointer);
|
||
if (TryResolveHandTargetFromRay(ray, out var rayTarget))
|
||
{
|
||
SetHandTarget(rayTarget);
|
||
return;
|
||
}
|
||
|
||
var plane = new Plane(
|
||
Vector3.up,
|
||
new Vector3(0f, _config.cardLift, 0f));
|
||
if (!plane.Raycast(ray, out var enter))
|
||
{
|
||
ClearHandTarget();
|
||
return;
|
||
}
|
||
|
||
var point = ray.GetPoint(enter);
|
||
point.x = Mathf.Clamp(
|
||
point.x,
|
||
-_config.boardHalfWidth,
|
||
_config.boardHalfWidth);
|
||
point.z = Mathf.Clamp(
|
||
point.z,
|
||
-_config.boardHalfLength,
|
||
_config.boardHalfLength);
|
||
|
||
if (_layout.TryFindDropTarget(point, _selfPlayer, out var target))
|
||
SetHandTarget(target);
|
||
else
|
||
ClearHandTarget();
|
||
}
|
||
|
||
private bool TryResolveHandTargetFromRay(
|
||
Ray ray,
|
||
out BoardDropTarget target)
|
||
{
|
||
target = default;
|
||
if (!TryRaycast(ray, out var card, out var slot, out _))
|
||
return false;
|
||
|
||
if (card != null
|
||
&& card.Ref.Zone is BoardZone.Vanguard
|
||
or BoardZone.RearGuard
|
||
or BoardZone.Guardian)
|
||
{
|
||
target = new BoardDropTarget(
|
||
card.Ref.Zone,
|
||
card.Ref.Player,
|
||
card.Ref.Slot,
|
||
card.Ref.Index);
|
||
return true;
|
||
}
|
||
|
||
if (slot != null)
|
||
{
|
||
var slotRef = slot.Ref;
|
||
target = new BoardDropTarget(
|
||
slotRef.Zone,
|
||
slotRef.Player,
|
||
slotRef.Slot,
|
||
slotRef.Index);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private void SetHandTarget(BoardDropTarget target)
|
||
{
|
||
_dragTarget = target;
|
||
ShowDropIndicator(target);
|
||
var key = new BoardSlotKey(target.Player, target.Slot);
|
||
var valid = target.Zone == BoardZone.Guardian
|
||
? _guardianDropAllowed
|
||
: _validDropSlots.Contains(key);
|
||
_dropIndicator?.SetColor(
|
||
valid ? _config.selectedColor : _config.damageColor);
|
||
}
|
||
|
||
private void ClearHandTarget()
|
||
{
|
||
_dragTarget = null;
|
||
if (_dropIndicator != null)
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void FinishHandTargeting(Vector2 pointer)
|
||
{
|
||
var source = _pressedCard != null
|
||
? _pressedCard.Ref
|
||
: _pressedCardRef;
|
||
var target = _dragTarget;
|
||
_handTargeting = false;
|
||
_dragTarget = null;
|
||
_dropIndicator?.gameObject.SetActive(false);
|
||
|
||
if (target.HasValue && !IsPointerOverUi())
|
||
{
|
||
Debug.Log($"[HandTargeting] drop source={source.Zone}:{source.Player}:{source.PrintingId} target={target.Value.Zone}:{target.Value.Player}:{target.Value.Slot}");
|
||
CardDropped?.Invoke(source, target.Value.ToRef());
|
||
}
|
||
|
||
CancelPointer();
|
||
}
|
||
|
||
private void UpdateDrag(Vector2 pointer)
|
||
{
|
||
if (!_dragging || _pressedCard == null)
|
||
return;
|
||
|
||
var ray = GetRay(pointer);
|
||
var plane = GetDragPlane(_pressedCard);
|
||
if (!plane.Raycast(ray, out var enter))
|
||
return;
|
||
|
||
var point = ray.GetPoint(enter);
|
||
point.x += _dragPointerOffset.x;
|
||
point.z += _dragPointerOffset.z;
|
||
point.x = Mathf.Clamp(point.x, -_config.boardHalfWidth, _config.boardHalfWidth);
|
||
point.z = Mathf.Clamp(point.z, -_config.boardHalfLength, _config.boardHalfLength);
|
||
_pressedCard.DragTo(point);
|
||
|
||
if (_layout.TryFindDropTarget(point, _selfPlayer, out var target))
|
||
{
|
||
_dragTarget = target;
|
||
ShowDropIndicator(target);
|
||
var key = new BoardSlotKey(target.Player, target.Slot);
|
||
var valid = target.Zone == BoardZone.Guardian
|
||
? _guardianDropAllowed
|
||
: _validDropSlots.Contains(key);
|
||
_dropIndicator.SetColor(valid ? _config.selectedColor : _config.damageColor);
|
||
}
|
||
else
|
||
{
|
||
_dragTarget = null;
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
}
|
||
}
|
||
|
||
private void FinishDrag(Vector2 pointer)
|
||
{
|
||
var card = _pressedCard;
|
||
var source = _pressedCardRef;
|
||
var target = _dragTarget;
|
||
_dragging = false;
|
||
_dragTarget = null;
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
|
||
if (card == null)
|
||
{
|
||
_dropPendingCard = null;
|
||
CancelPointer();
|
||
return;
|
||
}
|
||
|
||
if (IsPointerOverUi() || !target.HasValue)
|
||
{
|
||
if (_dropPendingCard == card)
|
||
_dropPendingCard = null;
|
||
card.ReturnToOrigin();
|
||
CancelPointer();
|
||
return;
|
||
}
|
||
|
||
var targetValue = target.Value;
|
||
var targetRef = targetValue.ToRef();
|
||
|
||
if (source.IsHand)
|
||
{
|
||
// Не возвращаем карту в руку до ответа сервера: она должна
|
||
// остаться приподнятой в точке drop-а и затем плавно уйти
|
||
// в VC/RC. Если drop отклонён, BattleView вызовет
|
||
// ReturnDroppedCard.
|
||
card.EndDragAtCurrent();
|
||
_dropPendingCard = card;
|
||
}
|
||
else
|
||
{
|
||
// Для карт поля сохраняем прежнее поведение: серверный
|
||
// снапшот сам построит дугу из исходной позиции.
|
||
card.ReturnToOrigin();
|
||
}
|
||
|
||
CardDropped?.Invoke(source, targetRef);
|
||
CancelPointer();
|
||
}
|
||
|
||
private void ShowDropIndicator(BoardDropTarget target)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(target.Player, _selfPlayer);
|
||
var position = target.Zone == BoardZone.Guardian
|
||
? _layout.GuardianPosition(localSeat, 0, 1)
|
||
: _layout.CirclePosition(localSeat, target.Slot);
|
||
_dropIndicator.transform.localPosition = new Vector3(
|
||
position.x,
|
||
0.014f,
|
||
position.z);
|
||
_dropIndicator.gameObject.SetActive(true);
|
||
}
|
||
|
||
private bool TryRaycast(
|
||
Ray ray,
|
||
out Card3D card,
|
||
out BoardSlotView slot,
|
||
out Pile3D pile)
|
||
{
|
||
card = null;
|
||
slot = null;
|
||
pile = null;
|
||
|
||
var hits = Physics.RaycastAll(ray, 100f, ~0, QueryTriggerInteraction.Ignore);
|
||
if (hits.Length == 0)
|
||
return false;
|
||
|
||
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
||
foreach (var hit in hits)
|
||
{
|
||
if (hit.collider == null)
|
||
continue;
|
||
|
||
card = hit.collider.GetComponentInParent<Card3D>();
|
||
if (card != null && card.gameObject.activeInHierarchy)
|
||
return true;
|
||
|
||
slot = hit.collider.GetComponentInParent<BoardSlotView>();
|
||
if (slot != null)
|
||
return true;
|
||
|
||
pile = hit.collider.GetComponentInParent<Pile3D>();
|
||
if (pile != null)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private Ray GetRay(Vector2 screenPoint)
|
||
{
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
return _camera != null
|
||
? _camera.ScreenPointToRay(screenPoint)
|
||
: new Ray(Vector3.up * 10f, Vector3.down);
|
||
}
|
||
|
||
private static bool IsPointerOverUi()
|
||
{
|
||
var eventSystem = EventSystem.current;
|
||
return eventSystem != null && eventSystem.IsPointerOverGameObject();
|
||
}
|
||
|
||
private Card3D RentCard()
|
||
{
|
||
while (_pool.Count > 0)
|
||
{
|
||
var pooled = _pool.Pop();
|
||
if (pooled == null)
|
||
continue;
|
||
pooled.gameObject.SetActive(true);
|
||
return ConfigureCardSpeeds(pooled);
|
||
}
|
||
|
||
if (_config.cardPrefab != null)
|
||
{
|
||
var instance = Instantiate(_config.cardPrefab, _cardsRoot);
|
||
return ConfigureCardSpeeds(instance);
|
||
}
|
||
|
||
if (_cardsRoot == null)
|
||
{
|
||
_cardsRoot = new GameObject("Cards").transform;
|
||
_cardsRoot.SetParent(transform, false);
|
||
}
|
||
|
||
return ConfigureCardSpeeds(Card3D.Create(_cardsRoot, _config));
|
||
}
|
||
|
||
private Card3D ConfigureCardSpeeds(Card3D card)
|
||
{
|
||
if (card != null && _animationConfig != null)
|
||
{
|
||
card.SetAnimationSpeeds(
|
||
_animationConfig.cardMoveSpeed,
|
||
_animationConfig.cardRotateSpeed);
|
||
}
|
||
|
||
return card;
|
||
}
|
||
|
||
private void ReleaseCard(Card3D card)
|
||
{
|
||
if (card == null)
|
||
return;
|
||
|
||
_moveAnimator?.CancelCard(card);
|
||
if (_hoveredHandCard == card)
|
||
_hoveredHandCard = null;
|
||
card.SetHighlight(null);
|
||
card.gameObject.SetActive(false);
|
||
_pendingDrawCards.Remove(card);
|
||
_visualHandCards.Remove(card);
|
||
if (_returningCards.Count == 0
|
||
&& _pendingDrawCards.Count == 0)
|
||
{
|
||
_handLayoutOverride = false;
|
||
_visualHandPlayer = -1;
|
||
}
|
||
|
||
_pool.Push(card);
|
||
}
|
||
|
||
private void ClearCards()
|
||
{
|
||
foreach (var card in _returningCards)
|
||
ReleaseCard(card);
|
||
_returningCards.Clear();
|
||
_visualHandCards.Clear();
|
||
_pendingDrawCards.Clear();
|
||
_handLayoutOverride = false;
|
||
_visualHandPlayer = -1;
|
||
_handReturnTargets.Clear();
|
||
|
||
foreach (var card in _cards.Values)
|
||
ReleaseCard(card);
|
||
_cards.Clear();
|
||
}
|
||
|
||
private void CancelPointer()
|
||
{
|
||
if (_pressedCard != null && _dragging)
|
||
_pressedCard.ReturnToOrigin();
|
||
_mode = PointerMode.None;
|
||
_pressedCard = null;
|
||
_pressedSlot = null;
|
||
_pressedPile = null;
|
||
_dragging = false;
|
||
_handTargeting = false;
|
||
_dragTarget = null;
|
||
_dragPointerOffset = Vector3.zero;
|
||
if (_dropIndicator != null)
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
ApplySlotHighlights();
|
||
}
|
||
|
||
private float DrawDuration => _animationConfig != null
|
||
? _animationConfig.introDrawDuration
|
||
: 0.6f;
|
||
|
||
private (Vector3 Position, Vector3 Scale) GetDrawSource(int player)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
|
||
var deck = player == _selfPlayer ? _deckSelf : _deckOpponent;
|
||
var position = (deck != null
|
||
? deck.transform.position
|
||
: _layout.DeckPosition(localSeat))
|
||
+ (Vector3.up * 0.03f);
|
||
return (position, Vector3.one * _config.cardScale);
|
||
}
|
||
|
||
private (Vector3 Position, Quaternion Rotation, Vector3 Scale) ResolveSourcePose(
|
||
CardSpec spec)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(
|
||
spec.Ref.Player,
|
||
_selfPlayer);
|
||
var baseYaw = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat),
|
||
0f);
|
||
var scale = Vector3.one * Mathf.Max(
|
||
0.05f,
|
||
spec.Scale * _config.cardScale);
|
||
|
||
if (spec.Ref.InstanceId != 0
|
||
&& _sourceByInstance != null
|
||
&& _sourceByInstance.TryGetValue(
|
||
spec.Ref.InstanceId,
|
||
out var pose))
|
||
{
|
||
return (pose.Position, pose.Rotation, scale);
|
||
}
|
||
|
||
// Новая карта руки: общий старт добора — над колодой, полного
|
||
// размера, рубашкой вверх в ориентации будущей карты руки.
|
||
if (spec.Ref.Zone == BoardZone.Hand)
|
||
{
|
||
var draw = GetDrawSource(spec.Ref.Player);
|
||
return (
|
||
draw.Position,
|
||
spec.BaseRotation * Card3D.FaceDownFlip,
|
||
draw.Scale);
|
||
}
|
||
|
||
// Fallback: карта пришла из колоды — появляется над колодой
|
||
// рубашкой вверх и летит в руку с переворотом.
|
||
var position = _layout.DeckPosition(localSeat)
|
||
+ (Vector3.up * 0.03f);
|
||
var rotation = baseYaw * Card3D.FaceDownFlip;
|
||
return (position, rotation, scale);
|
||
}
|
||
|
||
private Dictionary<int, SourcePose> BuildSourceMap()
|
||
{
|
||
var map = new Dictionary<int, SourcePose>();
|
||
if (_view == null)
|
||
return map;
|
||
|
||
foreach (var player in _view.Players)
|
||
{
|
||
if (player == null)
|
||
continue;
|
||
|
||
var localSeat = _layout.ToLocalSeat(player.Index, _selfPlayer);
|
||
var baseYaw = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat),
|
||
0f);
|
||
var cardScale = Vector3.one * _config.cardScale;
|
||
|
||
if (player.Soul != null)
|
||
{
|
||
var position = _layout.SoulPosition(localSeat);
|
||
foreach (var card in player.Soul)
|
||
{
|
||
if (card != null && card.InstanceId != 0)
|
||
map[card.InstanceId] = new SourcePose(
|
||
position,
|
||
baseYaw,
|
||
cardScale);
|
||
}
|
||
}
|
||
|
||
if (player.Drop != null)
|
||
{
|
||
var position = _layout.DropPosition(localSeat);
|
||
foreach (var card in player.Drop)
|
||
{
|
||
if (card != null && card.InstanceId != 0)
|
||
map[card.InstanceId] = new SourcePose(
|
||
position,
|
||
baseYaw,
|
||
cardScale);
|
||
}
|
||
}
|
||
|
||
if (player.FaceUpDamage != null)
|
||
{
|
||
for (var i = 0; i < player.FaceUpDamage.Count; i++)
|
||
{
|
||
var card = player.FaceUpDamage[i];
|
||
if (card == null || card.InstanceId == 0)
|
||
continue;
|
||
|
||
var position = _layout.DamagePosition(localSeat, i);
|
||
var rotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat) - 90f,
|
||
0f);
|
||
map[card.InstanceId] = new SourcePose(
|
||
position,
|
||
rotation,
|
||
cardScale);
|
||
}
|
||
}
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
private static bool CanReuseByKey(Card3D existing, CardSpec spec)
|
||
{
|
||
// Анонимные карты (InstanceId == 0) привязаны к ключу зоны/индекса.
|
||
// Если и старая, и новая карты реальны и разные — это замена,
|
||
// а не переворот/сдвиг, и нужна полноценная анимация из источника.
|
||
if (existing.Ref.InstanceId == 0 || spec.Ref.InstanceId == 0)
|
||
return true;
|
||
|
||
return existing.Ref.InstanceId == spec.Ref.InstanceId;
|
||
}
|
||
|
||
private static bool NeedsMove(BoardCardRef oldRef, BoardCardRef newRef)
|
||
{
|
||
if (oldRef.Zone == newRef.Zone && oldRef.Player == newRef.Player)
|
||
{
|
||
// Внутри одной зоны карты могут менять индекс/ключ из-за
|
||
// добавления или удаления соседней карты. Это перекомпоновка,
|
||
// а не перемещение по столу: дуговая анимация здесь лишняя.
|
||
switch (newRef.Zone)
|
||
{
|
||
case BoardZone.Hand:
|
||
case BoardZone.Guardian:
|
||
case BoardZone.Damage:
|
||
case BoardZone.Trigger:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return oldRef.Zone != newRef.Zone
|
||
|| oldRef.Player != newRef.Player
|
||
|| oldRef.Slot != newRef.Slot
|
||
|| oldRef.Index != newRef.Index;
|
||
}
|
||
|
||
private BoardArt ResolveArt(string printingId)
|
||
{
|
||
if (ArtProvider != null)
|
||
{
|
||
var art = ArtProvider(printingId);
|
||
if (art != null)
|
||
return art;
|
||
}
|
||
|
||
return BoardArt.Fallback(BoardTextures.AccentFor(printingId));
|
||
}
|
||
|
||
private int LocalToPlayer(int localSeat) =>
|
||
_layout.ToPlayer(localSeat, _selfPlayer);
|
||
|
||
private GameObject CreateBox(
|
||
string name,
|
||
Vector3 position,
|
||
Vector3 scale,
|
||
Color color)
|
||
{
|
||
return CreateBox(name, position, scale, BoardMaterials.CreateLit(color));
|
||
}
|
||
|
||
private GameObject CreateBox(
|
||
string name,
|
||
Vector3 position,
|
||
Vector3 scale,
|
||
Material material)
|
||
{
|
||
var box = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||
box.name = name;
|
||
box.transform.SetParent(transform, false);
|
||
box.transform.localPosition = position;
|
||
box.transform.localScale = scale;
|
||
|
||
var collider = box.GetComponent<Collider>();
|
||
if (collider != null)
|
||
{
|
||
if (Application.isPlaying)
|
||
Destroy(collider);
|
||
else
|
||
DestroyImmediate(collider);
|
||
}
|
||
|
||
var renderer = box.GetComponent<MeshRenderer>();
|
||
renderer.sharedMaterial = material;
|
||
BoardMaterials.SetShadows(renderer, false);
|
||
return box;
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
if (_instance == this)
|
||
_instance = null;
|
||
|
||
_moveAnimator?.CancelAll(snapCards: false);
|
||
_arrowDirector?.Dispose();
|
||
_cameraDirector?.Restore();
|
||
ReleaseBoardLock();
|
||
}
|
||
|
||
private void ReleaseBoardLock()
|
||
{
|
||
if (_boardLockReleaseAt <= 0f)
|
||
return;
|
||
|
||
_boardLockReleaseAt = -1f;
|
||
_inputLock?.Release("board");
|
||
}
|
||
|
||
private bool AnyCardAnimating()
|
||
{
|
||
if (_moveAnimator != null && _moveAnimator.IsBusy)
|
||
return true;
|
||
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card != null && card.IsAnimating)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private sealed class HandCardBinding
|
||
{
|
||
public HandCardBinding(Card3D card, int index, int count)
|
||
{
|
||
Card = card;
|
||
Index = index;
|
||
Count = count;
|
||
}
|
||
|
||
public Card3D Card { get; }
|
||
public int Index { get; }
|
||
public int Count { get; }
|
||
}
|
||
|
||
private sealed class CardAssignment
|
||
{
|
||
public Card3D Card;
|
||
public bool Transferred;
|
||
public BoardCardRef OldRef;
|
||
public string OldKey;
|
||
public Vector3 FromPosition;
|
||
public Quaternion FromRotation;
|
||
public Vector3 FromScale;
|
||
}
|
||
|
||
private readonly struct HandReturnRequest
|
||
{
|
||
public readonly int Player;
|
||
public readonly int Count;
|
||
public readonly string PrintingId;
|
||
|
||
public HandReturnRequest(int player, int count, string printingId)
|
||
{
|
||
Player = player;
|
||
Count = count;
|
||
PrintingId = printingId;
|
||
}
|
||
}
|
||
|
||
private readonly struct HandReturnTarget
|
||
{
|
||
public readonly Vector3 Position;
|
||
public readonly Quaternion BaseRotation;
|
||
public readonly float Scale;
|
||
|
||
public HandReturnTarget(
|
||
Vector3 position,
|
||
Quaternion baseRotation,
|
||
float scale)
|
||
{
|
||
Position = position;
|
||
BaseRotation = baseRotation;
|
||
Scale = scale;
|
||
}
|
||
}
|
||
|
||
private readonly struct SourcePose
|
||
{
|
||
public readonly Vector3 Position;
|
||
public readonly Quaternion Rotation;
|
||
public readonly Vector3 Scale;
|
||
|
||
public SourcePose(Vector3 position, Quaternion rotation, Vector3 scale)
|
||
{
|
||
Position = position;
|
||
Rotation = rotation;
|
||
Scale = scale;
|
||
}
|
||
}
|
||
|
||
private sealed class CardSpec
|
||
{
|
||
public string Key;
|
||
public BoardCardRef Ref;
|
||
public BoardArt Art;
|
||
public Vector3 Position;
|
||
public Quaternion BaseRotation;
|
||
public bool FaceUp;
|
||
public bool Rested;
|
||
public bool DamageLayout;
|
||
public float Scale = 1f;
|
||
public bool IsHand;
|
||
public bool IsOpponentHand;
|
||
public int HandIndex;
|
||
public int HandCount;
|
||
public string Stats;
|
||
public bool StatsModified;
|
||
public Color? Highlight;
|
||
}
|
||
}
|
||
} |