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 BoardZoneView[] _triggerZones = new BoardZoneView[2]; 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 PlaymatLayoutConfig _config; private PlaymatLayout _layout; private float _handViewportX; private MatchView _view; 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 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) { if (!_built) return; if (view == null) { ClearCards(); _view = null; return; } SetSelfPlayer(view.You); _view = view; state ??= new BoardRenderState(); if (_camera == null) _camera = Camera.main; _handBindings.Clear(); var specs = new List(40); BuildSpecs(view, state, specs); _moveAnimator ??= new CardMoveAnimator( _inputLock, _animationConfig != null ? _animationConfig : AnimationConfig.Default); 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 used = new HashSet(StringComparer.Ordinal); var immediateAll = !_hasRendered; foreach (var spec in specs) { used.Add(spec.Key); var instanceId = spec.Ref.InstanceId; var existingKey = _cards.TryGetValue(spec.Key, out var card) && card != null; var sameInstance = existingKey && card.Ref.InstanceId == instanceId; var transfer = false; if (!sameInstance && instanceId != 0 && previousByInstance.TryGetValue(instanceId, out var previous)) { if (_cards.TryGetValue(spec.Key, out var displaced) && displaced != null && displaced != previous) { ReleaseCard(displaced); } if (previousKeyByInstance.TryGetValue(instanceId, out var oldKey) && oldKey != spec.Key) { _cards.Remove(oldKey); } card = previous; transfer = true; } else if (!existingKey || !sameInstance) { if (existingKey && card != null) ReleaseCard(card); card = RentCard(); } _cards[spec.Key] = card; var wasActive = card.gameObject.activeSelf; var fromPosition = card.transform.position; var fromRotation = card.transform.rotation; var fromScale = card.transform.localScale; var fromInstance = card.Ref.InstanceId; var fromZone = card.Ref.Zone; 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)); var moved = !immediateAll && (!wasActive || transfer || !existingKey || fromInstance != instanceId || fromZone != spec.Ref.Zone); if (moved) { Vector3 sourcePosition; Quaternion sourceRotation; Vector3 sourceScale; if (transfer || (existingKey && fromInstance == instanceId)) { sourcePosition = fromPosition; sourceRotation = fromRotation; sourceScale = fromScale; } else { var source = SourcePose(spec); sourcePosition = source.Position; sourceRotation = source.Rotation; sourceScale = Vector3.one * Mathf.Max( 0.05f, spec.Scale * _config.cardScale); card.PlaceImmediate( sourcePosition, sourceRotation, sourceScale); } 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, sourcePosition, sourceRotation, sourceScale, duration, arc); } card.SetHighlight(spec.Highlight); card.SetStats(spec.Stats, spec.StatsModified); if (spec.IsHand) { _handBindings.Add(new HandCardBinding( card, spec.HandIndex, spec.HandCount)); } } if (_cards.Count > 0) { var stale = _cards .Where(pair => !used.Contains(pair.Key)) .Select(pair => pair.Key) .ToList(); foreach (var key in stale) { ReleaseCard(_cards[key]); _cards.Remove(key); } } 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; _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 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), // Карты в кругах 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 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) || 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, }); } } /// /// Рука висит в поле зрения камеры снизу по центру и всегда смотрит /// на игрока. При отсутствии камеры используется старый настольный /// вариант — для 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; 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) 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 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(); 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; 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) SourcePose( CardSpec spec) { var localSeat = _layout.ToLocalSeat( spec.Ref.Player, _selfPlayer); var position = _layout.DeckPosition(localSeat); var rotation = Quaternion.Euler( 0f, _layout.BaseYaw(localSeat), 0f); return ( position, rotation, Vector3.one * Mathf.Max( 0.05f, spec.Scale * _config.cardScale)); } 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 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; } } }