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

1940 lines
66 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 BoardZoneView[] _triggerZones = new BoardZoneView[2];
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 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 BoardDropTarget? _dragTarget;
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 BoardArtProvider ArtProvider { get; set; }
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 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 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();
_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();
var specs = new List<CardSpec>(40);
BuildSpecs(view, state, specs);
_moveAnimator ??= new CardMoveAnimator(
_inputLock,
_animationConfig != null
? _animationConfig
: AnimationConfig.Default);
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;
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;
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;
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,
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
|| !string.Equals(
assignment.OldKey,
spec.Key,
StringComparison.Ordinal));
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);
}
card.Bind(spec.Ref);
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 (moved && animateMoves)
{
var important = spec.Ref.Zone is
BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop;
var duration = important
? _animationConfig != null
? _animationConfig.importantMoveDuration
: 0.9f
: _animationConfig != null
? _animationConfig.moveDuration
: 0.5f;
var arc = _animationConfig != null
? _animationConfig.arcHeight
: 0.45f;
_moveAnimator.Enqueue(
card,
assignment.FromPosition,
assignment.FromRotation,
assignment.FromScale,
duration,
arc);
}
else if (moved && !animateMoves)
{
Debug.LogWarning(
"[GameBoard3D] Local render moved card "
+ spec.Ref
+ " without animation.");
}
card.SetHighlight(spec.Highlight);
card.SetStats(spec.Stats, spec.StatsModified);
if (spec.IsHand)
{
_handBindings.Add(new HandCardBinding(
card,
spec.HandIndex,
spec.HandCount));
}
}
UpdatePiles(view);
UpdateSlots(state);
UpdateAttackArrowState(view);
_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();
_handBindings.Clear();
_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;
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),
// Карты в кругах V/R всегда открыты, независимо от снимка.
FaceUp = true,
Rested = card.Rested,
Stats = "P" + card.Power + " C" + card.Critical,
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 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)
|| state.MulliganSelecting)
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;
var z = _config.handViewDistance
+ Mathf.Abs(offset) * _config.handViewArc;
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 void UpdateHandView()
{
if (_handBindings.Count == 0)
return;
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);
binding.Card.SetPose(
position,
rotation,
true,
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++)
{
var pose = _layout.GetOpponentHandPose(i, count);
specs.Add(new CardSpec
{
Key = "OH:" + opponent.Index + ":" + i,
Ref = BoardCardRef.Hand(opponent.Index, i, null),
Art = BoardArt.Fallback(_config.matOpponentColor),
Position = pose.Position,
BaseRotation = Quaternion.Euler(-90f, 0f, pose.Yaw),
FaceUp = false,
Rested = false,
Scale = _config.opponentHandScale,
});
}
}
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);
if (_guardianZone != null)
_guardianZone.SetLabel(string.Empty);
}
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);
_cameraDirector?.Tick(delta);
foreach (var card in _cards.Values)
{
if (card != null)
card.Tick(delta);
}
_arrowDirector?.Tick(delta);
UpdateAttackArrowPoints();
if (_inputLock != null && _inputLock.IsLocked)
{
if (_mode != PointerMode.None || _dragging)
CancelPointer();
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))
return;
if (pressed)
OnPointerPressed(pointer);
else if (held && _mode == PointerMode.Card && !_dragging)
OnPointerHeld(pointer);
else if (released)
OnPointerReleased(pointer);
}
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)
{
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;
_dragging = true;
_pressedCard.BeginDrag();
ApplySlotHighlights();
UpdateDrag(pointer);
}
private void OnPointerReleased(Vector2 pointer)
{
if (_dragging)
{
UpdateDrag(pointer);
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 UpdateDrag(Vector2 pointer)
{
if (!_dragging || _pressedCard == null)
return;
var ray = GetRay(pointer);
var plane = new Plane(Vector3.up, new Vector3(0f, _config.cardLift, 0f));
if (!plane.Raycast(ray, out var enter))
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);
_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)
{
CancelPointer();
return;
}
if (IsPointerOverUi() || !target.HasValue)
{
card.ReturnToOrigin();
CancelPointer();
return;
}
var targetValue = target.Value;
var targetRef = targetValue.ToRef();
if (source.IsHand)
_handBindings.RemoveAll(binding => binding.Card == card);
// Атака — это клик по цели, карта остаётся на своём круге.
if ((source.IsField && targetValue.Player != source.Player)
|| (source.IsHand && targetValue.Player != source.Player
&& targetValue.Zone != BoardZone.Guardian))
{
card.ReturnToOrigin();
}
else
{
var localSeat = _layout.ToLocalSeat(targetValue.Player, _selfPlayer);
var position = targetValue.Zone == BoardZone.Guardian
? _layout.GuardianPosition(localSeat, 0, 1)
: _layout.CirclePosition(localSeat, targetValue.Slot);
var restYaw = !source.IsHand && card.IsRested ? 90f : 0f;
var rotation = Quaternion.Euler(
0f,
_layout.BaseYaw(localSeat) + restYaw,
0f);
card.DropAt(position, rotation);
}
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);
card.SetHighlight(null);
card.gameObject.SetActive(false);
_pool.Push(card);
}
private void ClearCards()
{
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;
_dragTarget = null;
if (_dropIndicator != null)
_dropIndicator.gameObject.SetActive(false);
ApplySlotHighlights();
}
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);
}
// Fallback: карта пришла из колоды — начинаем рубашкой вверх.
var position = _layout.DeckPosition(localSeat);
var rotation = baseYaw * Quaternion.Euler(180f, 0f, 0f);
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 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 string OldKey;
public Vector3 FromPosition;
public Quaternion FromRotation;
public Vector3 FromScale;
}
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 int HandIndex;
public int HandCount;
public string Stats;
public bool StatsModified;
public Color? Highlight;
}
}
}