using System; using System.Collections.Generic; using System.Linq; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.InputSystem; 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 readonly List _piles = new List(); private readonly BoardZoneView[] _damageZones = new BoardZoneView[2]; private readonly HashSet _activeHighlights = new HashSet(); private readonly HashSet _validDropSlots = new HashSet(); private readonly List _handBindings = new List(); private bool _guardianDropAllowed; private PlaymatLayoutConfig _config; private PlaymatLayout _layout; 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 BoardCameraRig _cameraRig; private bool _ownsRig; private BoardZoneView _guardianZone; private BoardZoneView _dropIndicator; private Pile3D _deckSelf; private Pile3D _deckOpponent; private Pile3D _dropSelf; private Pile3D _dropOpponent; private Pile3D _soulSelf; private Pile3D _soulOpponent; 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 BoardArtProvider ArtProvider { get; set; } public event Action CardClicked; public event Action SlotClicked; public event Action CardDropped; public event Action PileClicked; 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); _selfPlayer = 0; _built = true; BuildTable(); BuildSlots(); BuildZones(); BuildPiles(); BuildDropIndicator(); _cardsRoot = new GameObject("Cards").transform; _cardsRoot.SetParent(transform, false); EnsureCamera(); 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)); _soulSelf.SetPlayer(LocalToPlayer(0)); _deckOpponent.SetPlayer(LocalToPlayer(1)); _dropOpponent.SetPlayer(LocalToPlayer(1)); _soulOpponent.SetPlayer(LocalToPlayer(1)); _hasRendered = false; } public void SetVisible(bool visible) { if (!visible) { CancelPointer(); _hasRendered = false; _handBindings.Clear(); } else { EnsureCamera(); } gameObject.SetActive(visible); if (_cameraRig != null) _cameraRig.enabled = 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); var used = new HashSet(StringComparer.Ordinal); var immediateAll = !_hasRendered; foreach (var spec in specs) { used.Add(spec.Key); if (!_cards.TryGetValue(spec.Key, out var card) || card == null) { card = RentCard(); _cards[spec.Key] = card; } var changed = !card.gameObject.activeSelf || card.Ref.InstanceId != spec.Ref.InstanceId || card.Ref.Zone != spec.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 || changed, spec.DamageLayout, spec.Scale); card.SetHighlight(spec.Highlight); 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); _hasRendered = true; } public void Clear() { ClearCards(); _handBindings.Clear(); _view = null; _hasRendered = false; } 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.CreateLit(_config.lineColor, 1.2f); CreateBox( "Center Line", new Vector3(0f, 0.011f, 0f), new Vector3(width - 0.3f, 0.012f, 0.055f), divider); var railMaterial = BoardMaterials.CreateLit(_config.lineColor, 0.9f); 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, "GUARDIAN", 0.11f, -(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.15f)), 1.72f, 2.25f, Mathf.Max(0.018f, _config.cardWidth * 0.03f), _config.damageColor, "DAMAGE", 0.1f, -zSign * 1.32f, new Color(1f, 0.78f, 0.76f, 0.75f), _config.highlightColor); _damageZones[localSeat] = zone; } } private void BuildPiles() { _pilesRoot = new GameObject("Piles").transform; _pilesRoot.SetParent(transform, false); _deckSelf = CreatePile(BoardZone.Deck, 0, 0, "DECK", new Color(0.20f, 0.34f, 0.60f)); _deckOpponent = CreatePile(BoardZone.Deck, 1, 1, "DECK", new Color(0.60f, 0.26f, 0.26f)); _dropSelf = CreatePile(BoardZone.Drop, 0, 0, "DROP", new Color(0.30f, 0.30f, 0.40f)); _dropOpponent = CreatePile(BoardZone.Drop, 1, 1, "DROP", new Color(0.40f, 0.28f, 0.28f)); _soulSelf = CreatePile(BoardZone.Soul, 0, 0, "SOUL", _config.soulColor); _soulOpponent = CreatePile(BoardZone.Soul, 1, 1, "SOUL", _config.soulColor); } 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 EnsureCamera() { if (_camera == null) _camera = Camera.main; if (_camera == null) return; if (_cameraRig == null) { _cameraRig = _camera.GetComponent(); if (_cameraRig == null) { _cameraRig = _camera.gameObject.AddComponent(); _ownsRig = true; } } _cameraRig.Configure(_config); } 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); } AddHandSpecs(view, state, 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, Highlight = 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(); var total = guardians.Count + battleCards.Count; if (total == 0) return; for (var i = 0; i < guardians.Count; i++) { var card = guardians[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), 0f), FaceUp = true, Rested = false, }); } for (var i = 0; i < battleCards.Count; i++) { var card = battleCards[i]; specs.Add(new CardSpec { Key = "BG:" + player.Index + ":" + card.InstanceId, Ref = BoardCardRef.Guardian(player.Index, guardians.Count + i, card), Art = ResolveArt(card.PrintingId), Position = _layout.GuardianPosition( localSeat, guardians.Count + i, total), BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 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, 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 Color? DamageHighlight(BoardRenderState state, int index) { if (!state.DamageChoice && !state.AbilityDamageTargeting) return null; if (state.AbilityDamageTargeting && state.AbilityDamageIndex == 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.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 x = 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); } } 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, _soulSelf); UpdatePileSide(opponent, _deckOpponent, _dropOpponent, _soulOpponent); var damageSelf = self != null ? self.FaceDownDamage + (self.FaceUpDamage?.Count ?? 0) : 0; var damageOpponent = opponent != null ? opponent.FaceDownDamage + (opponent.FaceUpDamage?.Count ?? 0) : 0; _damageZones[0]?.SetLabel("DAMAGE " + damageSelf); _damageZones[1]?.SetLabel("DAMAGE " + damageOpponent); if (_guardianZone != null) { var shield = view.Battle != null ? view.Battle.GuardShield : 0; var checks = view.Battle != null ? view.Battle.ChecksRemaining : 0; _guardianZone.SetLabel( "GUARDIAN SHIELD " + shield + " CHECK " + checks); } } private void UpdatePileSide( PlayerView player, Pile3D deck, Pile3D drop, Pile3D soul) { if (deck == null) return; if (player == null) { deck.SetCount(0, null, "DECK", Color.white); drop.SetCount(0, null, "DROP", Color.white); soul.SetCount(0, null, "SOUL", _config.soulColor); return; } deck.SetCount( player.DeckCount, null, "DECK", 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 ? ResolveArt(topDrop.PrintingId).Front : null, "DROP", new Color(0.30f, 0.30f, 0.40f)); var topSoul = player.Soul != null && player.Soul.Count > 0 ? player.Soul[player.Soul.Count - 1] : null; soul.SetCount( player.Soul?.Count ?? 0, topSoul != null ? ResolveArt(topSoul.PrintingId).Front : null, "SOUL", _config.soulColor); } private void UpdateSlots(BoardRenderState state) { _activeHighlights.Clear(); foreach (var key in state.Highlights) _activeHighlights.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 highlighted = _activeHighlights.Contains(slot.Key) || (_dragging && _validDropSlots.Contains(slot.Key)); slot.SetHighlighted(highlighted); } if (_guardianZone != null) { _guardianZone.SetHighlighted(_dragging && _guardianDropAllowed); } } private void Update() { UpdateHandView(); var delta = Time.unscaledDeltaTime; foreach (var card in _cards.Values) { if (card != null) card.Tick(delta); } HandlePointer(); } 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.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 pooled; } if (_config.cardPrefab != null) { var instance = Instantiate(_config.cardPrefab, _cardsRoot); return instance; } if (_cardsRoot == null) { _cardsRoot = new GameObject("Cards").transform; _cardsRoot.SetParent(transform, false); } return Card3D.Create(_cardsRoot, _config); } 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 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; if (_cameraRig != null && _ownsRig) { if (Application.isPlaying) Destroy(_cameraRig); else DestroyImmediate(_cameraRig); } } 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 Color? Highlight; } } }