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 BoardZoneView[] _triggerZones = 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 BoardZoneView _guardianZone; private BoardZoneView _dropIndicator; 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 BoardArtProvider ArtProvider { get; set; } 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); _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)); _deckOpponent.SetPlayer(LocalToPlayer(1)); _dropOpponent.SetPlayer(LocalToPlayer(1)); _hasRendered = false; } public void SetVisible(bool visible) { if (!visible) { CancelPointer(); _hasRendered = false; _handBindings.Clear(); } 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); 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); 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); _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.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), "TRIGGER", 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, "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)); } 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; _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.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 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.identity, FaceUp = true, Rested = false, }); } } 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 halfWidth = halfHeight * Mathf.Max(0.1f, _camera.aspect); var x = ((_config.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); } } 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, "DECK", Color.white); drop.SetCount(0, null, "DROP", Color.white); 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)); } 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); } 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 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; } 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; } } }