VanguardReMaster/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs

3210 lines
110 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 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;
}
}
}