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 { /// /// 3D-поле: стол, круги, общий круг защиты, стопки, веер руки и карты. /// Отрисовка полностью процедурная; материалы и меши переиспользуются. /// public sealed class GameBoard3D : MonoBehaviour { private const string RootName = "GameBoard3D"; private static GameBoard3D _instance; private readonly List _slots = new List(); private readonly Dictionary _slotByKey = new Dictionary(); private readonly Dictionary _cards = new Dictionary(StringComparer.Ordinal); private readonly Stack _pool = new Stack(); private CardMoveAnimator _moveAnimator; private CameraDirector _cameraDirector; private readonly List _piles = new List(); 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 _activeHighlights = new HashSet(); private readonly HashSet _selectedHighlights = new HashSet(); private readonly HashSet _validDropSlots = new HashSet(); private readonly List _handBindings = new List(); private bool _guardianDropAllowed; private bool _handVisible = true; private bool _opponentHandVisible = true; private readonly List _opponentHandBindings = new List(); private readonly List _drawSelfCards = new List(); private readonly List _drawOpponentCards = new List(); private readonly List _returningCards = new List(); private readonly List _visualHandCards = new List(); private readonly HashSet _pendingDrawCards = new HashSet(); private readonly List _pendingHandReturns = new List(); private readonly Dictionary _handReturnTargets = new Dictionary(); 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 _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 ReturningCards => _returningCards; public bool StagedReturnHandActive => _handLayoutOverride && _returningCards.Count > 0; public IReadOnlyList StagedReturnCards => _visualHandCards; public bool StagedDrawHandActive => _handLayoutOverride && _pendingDrawCards.Count > 0; public IReadOnlyList 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(); if (request.Player == _selfPlayer) { var newIds = new HashSet( (newPlayer.Hand ?? new List()) .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( newSelf.Hand.Select(card => card.InstanceId)); var returningIds = new HashSet( _returningCards .Where(card => card != null && card.Ref.Zone == BoardZone.Hand && card.Ref.Player == player) .Select(card => card.Ref.InstanceId)); var byInstance = new Dictionary(); 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); } } } /// /// Вернуть карту, удерживаемую после drag-а, в руку. Вызывается, /// если drop не превратился в интент или сервер его отклонил. /// public void ReturnDroppedCard() { var card = _dropPendingCard; _dropPendingCard = null; if (card == null) return; card.CancelHold(); UpdateHandView(); } /// /// Поставить в очередь возврат карт из руки в колоду. Карты уходят /// по дуге под колоду с переворотом рубашкой вверх; следующая /// стартует после завершения предыдущей. /// 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 CardClicked; public event Action CardRightClicked; public event Action SlotClicked; public event Action CardDropped; public event Action PileClicked; public event Action 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(); 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(40); BuildSpecs(view, state, specs); _moveAnimator ??= new CardMoveAnimator( _inputLock, _animationConfig != null ? _animationConfig : AnimationConfig.Default); ProcessPendingHandReturns(view); var previousByInstance = new Dictionary(); var previousKeyByInstance = new Dictionary(); 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(); var assignedCards = new HashSet(); var assignments = new Dictionary( 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 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 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 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 specs) { var guardians = player.Guardian ?? new List(); var battleCards = view.Battle != null && view.Battle.AttackerPlayer != player.Index ? view.Battle.GuardCards ?? new List() : new List(); // 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 specs) { var faceUp = player.FaceUpDamage ?? new List(); 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 specs) { var triggers = player.Trigger ?? new List(); 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 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, }); } } /// /// Рука висит в поле зрения камеры снизу по центру и всегда смотрит /// на игрока. При отсутствии камеры используется старый настольный /// вариант — для EditMode-проверок. /// 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 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(); if (card != null && card.gameObject.activeInHierarchy) return true; slot = hit.collider.GetComponentInParent(); if (slot != null) return true; pile = hit.collider.GetComponentInParent(); 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 BuildSourceMap() { var map = new Dictionary(); 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(); if (collider != null) { if (Application.isPlaying) Destroy(collider); else DestroyImmediate(collider); } var renderer = box.GetComponent(); 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; } } }