1940 lines
66 KiB
C#
1940 lines
66 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using UnityEngine;
|
||
using UnityEngine.EventSystems;
|
||
using UnityEngine.InputSystem;
|
||
using Vanguard.Battle;
|
||
using Vanguard.Shared.Protocol;
|
||
|
||
namespace Vanguard.Board3D
|
||
{
|
||
/// <summary>
|
||
/// 3D-поле: стол, круги, общий круг защиты, стопки, веер руки и карты.
|
||
/// Отрисовка полностью процедурная; материалы и меши переиспользуются.
|
||
/// </summary>
|
||
public sealed class GameBoard3D : MonoBehaviour
|
||
{
|
||
private const string RootName = "GameBoard3D";
|
||
|
||
private static GameBoard3D _instance;
|
||
|
||
private readonly List<BoardSlotView> _slots = new List<BoardSlotView>();
|
||
private readonly Dictionary<BoardSlotKey, BoardSlotView> _slotByKey =
|
||
new Dictionary<BoardSlotKey, BoardSlotView>();
|
||
private readonly Dictionary<string, Card3D> _cards =
|
||
new Dictionary<string, Card3D>(StringComparer.Ordinal);
|
||
private readonly Stack<Card3D> _pool = new Stack<Card3D>();
|
||
private CardMoveAnimator _moveAnimator;
|
||
private CameraDirector _cameraDirector;
|
||
private readonly List<Pile3D> _piles = new List<Pile3D>();
|
||
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
|
||
private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
|
||
private readonly HashSet<BoardSlotKey> _activeHighlights = new HashSet<BoardSlotKey>();
|
||
private readonly HashSet<BoardSlotKey> _selectedHighlights = new HashSet<BoardSlotKey>();
|
||
private readonly HashSet<BoardSlotKey> _validDropSlots = new HashSet<BoardSlotKey>();
|
||
private readonly List<HandCardBinding> _handBindings = new List<HandCardBinding>();
|
||
private bool _guardianDropAllowed;
|
||
|
||
private PlaymatLayoutConfig _config;
|
||
private PlaymatLayout _layout;
|
||
private float _handViewportX;
|
||
private MatchView _view;
|
||
private MatchView _previousView;
|
||
private Dictionary<int, SourcePose> _sourceByInstance;
|
||
private int _selfPlayer;
|
||
private bool _hasRendered;
|
||
private bool _built;
|
||
|
||
private Transform _slotsRoot;
|
||
private Transform _cardsRoot;
|
||
private Transform _pilesRoot;
|
||
private Transform _zonesRoot;
|
||
private Camera _camera;
|
||
private InputLockService _inputLock;
|
||
private AnimationConfig _animationConfig;
|
||
private float _boardLockReleaseAt = -1f;
|
||
|
||
private BoardZoneView _guardianZone;
|
||
private BoardZoneView _dropIndicator;
|
||
private ArrowDirector _arrowDirector;
|
||
private Pile3D _deckSelf;
|
||
private Pile3D _deckOpponent;
|
||
private Pile3D _dropSelf;
|
||
private Pile3D _dropOpponent;
|
||
|
||
private enum PointerMode
|
||
{
|
||
None,
|
||
Card,
|
||
Slot,
|
||
Pile,
|
||
}
|
||
|
||
private PointerMode _mode;
|
||
private Card3D _pressedCard;
|
||
private BoardCardRef _pressedCardRef;
|
||
private BoardSlotView _pressedSlot;
|
||
private Pile3D _pressedPile;
|
||
private Vector2 _pressPosition;
|
||
private bool _dragging;
|
||
private BoardDropTarget? _dragTarget;
|
||
|
||
public PlaymatLayoutConfig Config => _config;
|
||
public PlaymatLayout Layout => _layout;
|
||
public int SelfPlayer => _selfPlayer;
|
||
public bool AttackArrowVisible =>
|
||
_arrowDirector != null && _arrowDirector.AttackVisible;
|
||
public CardMoveAnimator MoveAnimator => _moveAnimator;
|
||
public CameraDirector CameraDirector => _cameraDirector;
|
||
public BoardArtProvider ArtProvider { get; set; }
|
||
|
||
public Vector3 TriggerCameraPoint(int player)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
|
||
var position = _layout.TriggerPosition(localSeat, 0, 1);
|
||
return position + (Vector3.up * 0.5f);
|
||
}
|
||
|
||
public bool TryGetCardScreenPosition(BoardCardRef reference, out Vector2 screen)
|
||
{
|
||
screen = default;
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
if (_camera == null)
|
||
return false;
|
||
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card == null || !card.Ref.Equals(reference))
|
||
continue;
|
||
|
||
var point = _camera.WorldToScreenPoint(card.transform.position);
|
||
if (point.z <= 0f)
|
||
return false;
|
||
|
||
screen = new Vector2(point.x, point.y);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
public void SetHandViewportX(float value)
|
||
{
|
||
_handViewportX = Mathf.Clamp(value, 0.05f, 0.95f);
|
||
}
|
||
|
||
public void ConfigureRuntime(
|
||
AnimationConfig animationConfig,
|
||
InputLockService inputLock)
|
||
{
|
||
_animationConfig = animationConfig;
|
||
_inputLock = inputLock;
|
||
_arrowDirector?.SetConfig(
|
||
_animationConfig,
|
||
ArrowConfig.LoadOrDefault());
|
||
_moveAnimator ??= new CardMoveAnimator(
|
||
_inputLock,
|
||
_animationConfig);
|
||
_moveAnimator.SetConfig(_animationConfig);
|
||
|
||
if (_animationConfig == null)
|
||
return;
|
||
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
card.SetAnimationSpeeds(
|
||
_animationConfig.cardMoveSpeed,
|
||
_animationConfig.cardRotateSpeed);
|
||
}
|
||
}
|
||
|
||
public event Action<BoardCardRef> CardClicked;
|
||
public event Action<BoardCardRef> CardRightClicked;
|
||
public event Action<BoardCardRef> SlotClicked;
|
||
public event Action<BoardCardRef, BoardCardRef> CardDropped;
|
||
public event Action<BoardCardRef> PileClicked;
|
||
public event Action<BoardCardRef> PileRightClicked;
|
||
|
||
public static GameBoard3D Create(Transform parent, PlaymatLayoutConfig config = null)
|
||
{
|
||
if (_instance != null)
|
||
{
|
||
var previous = _instance.gameObject;
|
||
if (Application.isPlaying)
|
||
Destroy(previous);
|
||
else
|
||
DestroyImmediate(previous);
|
||
}
|
||
|
||
var go = new GameObject(RootName);
|
||
if (parent != null)
|
||
go.transform.SetParent(parent, false);
|
||
|
||
var board = go.AddComponent<GameBoard3D>();
|
||
board.Initialize(config);
|
||
_instance = board;
|
||
return board;
|
||
}
|
||
|
||
public void Initialize(PlaymatLayoutConfig config)
|
||
{
|
||
if (_built)
|
||
return;
|
||
|
||
_config = config != null ? config : PlaymatLayoutConfig.Default;
|
||
_layout = new PlaymatLayout(_config);
|
||
_handViewportX = _config.handViewportX;
|
||
_selfPlayer = 0;
|
||
_built = true;
|
||
|
||
BuildTable();
|
||
BuildSlots();
|
||
BuildZones();
|
||
BuildPiles();
|
||
BuildDropIndicator();
|
||
BuildAttackArrow();
|
||
|
||
_cardsRoot = new GameObject("Cards").transform;
|
||
_cardsRoot.SetParent(transform, false);
|
||
|
||
EnsureCamera();
|
||
_cameraDirector = new CameraDirector(_camera);
|
||
SetVisible(false);
|
||
}
|
||
|
||
public void SetSelfPlayer(int player)
|
||
{
|
||
player = player == 0 ? 0 : 1;
|
||
if (_selfPlayer == player)
|
||
return;
|
||
|
||
_selfPlayer = player;
|
||
foreach (var slot in _slots)
|
||
slot.SetPlayer(LocalToPlayer(slot.LocalSeat));
|
||
|
||
_deckSelf.SetPlayer(LocalToPlayer(0));
|
||
_dropSelf.SetPlayer(LocalToPlayer(0));
|
||
_deckOpponent.SetPlayer(LocalToPlayer(1));
|
||
_dropOpponent.SetPlayer(LocalToPlayer(1));
|
||
_hasRendered = false;
|
||
}
|
||
|
||
public void SetVisible(bool visible)
|
||
{
|
||
if (!visible)
|
||
{
|
||
CancelPointer();
|
||
_hasRendered = false;
|
||
_moveAnimator?.CancelAll(snapCards: false);
|
||
_handBindings.Clear();
|
||
_arrowDirector?.HideImmediate();
|
||
ReleaseBoardLock();
|
||
}
|
||
else
|
||
{
|
||
EnsureCamera();
|
||
}
|
||
|
||
gameObject.SetActive(visible);
|
||
}
|
||
|
||
public void Render(MatchView view, BoardRenderState state, bool animateMoves = true)
|
||
{
|
||
if (!_built)
|
||
return;
|
||
|
||
if (view == null)
|
||
{
|
||
ClearCards();
|
||
_view = null;
|
||
_previousView = null;
|
||
_sourceByInstance = null;
|
||
return;
|
||
}
|
||
|
||
SetSelfPlayer(view.You);
|
||
|
||
// Запоминаем старые позиции карт по InstanceId, пока не заменили _view.
|
||
_sourceByInstance = BuildSourceMap();
|
||
_previousView = _view;
|
||
_view = view;
|
||
|
||
state ??= new BoardRenderState();
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
|
||
_handBindings.Clear();
|
||
var specs = new List<CardSpec>(40);
|
||
BuildSpecs(view, state, specs);
|
||
|
||
_moveAnimator ??= new CardMoveAnimator(
|
||
_inputLock,
|
||
_animationConfig != null
|
||
? _animationConfig
|
||
: AnimationConfig.Default);
|
||
|
||
var previousByInstance = new Dictionary<int, Card3D>();
|
||
var previousKeyByInstance = new Dictionary<int, string>();
|
||
foreach (var pair in _cards)
|
||
{
|
||
var previous = pair.Value;
|
||
if (previous == null || previous.Ref.InstanceId == 0)
|
||
continue;
|
||
|
||
previousByInstance[previous.Ref.InstanceId] = previous;
|
||
previousKeyByInstance[previous.Ref.InstanceId] = pair.Key;
|
||
}
|
||
|
||
var immediateAll = !_hasRendered;
|
||
var assignedInstances = new HashSet<int>();
|
||
var assignedCards = new HashSet<Card3D>();
|
||
var assignments = new Dictionary<string, CardAssignment>(
|
||
StringComparer.Ordinal);
|
||
|
||
foreach (var spec in specs)
|
||
{
|
||
var transferred = false;
|
||
string oldKey = null;
|
||
var fromPosition = default(Vector3);
|
||
var fromRotation = default(Quaternion);
|
||
var fromScale = default(Vector3);
|
||
Card3D card = null;
|
||
|
||
// 1) Переиспользуем анонимные карты по стабильному ключу:
|
||
// скрытая рука противника, закрытый урон и т.п.
|
||
if (_cards.TryGetValue(spec.Key, out var existing)
|
||
&& existing != null
|
||
&& !assignedCards.Contains(existing)
|
||
&& CanReuseByKey(existing, spec))
|
||
{
|
||
card = existing;
|
||
transferred = true;
|
||
oldKey = spec.Key;
|
||
fromPosition = card.transform.position;
|
||
fromRotation = card.transform.rotation;
|
||
fromScale = card.transform.localScale;
|
||
|
||
if (spec.Ref.InstanceId != 0)
|
||
assignedInstances.Add(spec.Ref.InstanceId);
|
||
if (existing.Ref.InstanceId != 0)
|
||
assignedInstances.Add(existing.Ref.InstanceId);
|
||
}
|
||
// 2) Перемещаем реальную карту по InstanceId.
|
||
else if (spec.Ref.InstanceId != 0
|
||
&& previousByInstance.TryGetValue(
|
||
spec.Ref.InstanceId,
|
||
out var previous)
|
||
&& assignedInstances.Add(spec.Ref.InstanceId))
|
||
{
|
||
card = previous;
|
||
transferred = true;
|
||
previousKeyByInstance.TryGetValue(
|
||
spec.Ref.InstanceId,
|
||
out oldKey);
|
||
fromPosition = card.transform.position;
|
||
fromRotation = card.transform.rotation;
|
||
fromScale = card.transform.localScale;
|
||
}
|
||
else
|
||
{
|
||
card = RentCard();
|
||
}
|
||
|
||
assignedCards.Add(card);
|
||
assignments[spec.Key] = new CardAssignment
|
||
{
|
||
Card = card,
|
||
Transferred = transferred,
|
||
OldKey = oldKey,
|
||
FromPosition = fromPosition,
|
||
FromRotation = fromRotation,
|
||
FromScale = fromScale,
|
||
};
|
||
}
|
||
|
||
foreach (var pair in _cards)
|
||
{
|
||
if (!assignedCards.Contains(pair.Value))
|
||
ReleaseCard(pair.Value);
|
||
}
|
||
|
||
_cards.Clear();
|
||
foreach (var pair in assignments)
|
||
_cards[pair.Key] = pair.Value.Card;
|
||
|
||
foreach (var spec in specs)
|
||
{
|
||
var assignment = assignments[spec.Key];
|
||
var card = assignment.Card;
|
||
var moved = !immediateAll
|
||
&& (!assignment.Transferred
|
||
|| !string.Equals(
|
||
assignment.OldKey,
|
||
spec.Key,
|
||
StringComparison.Ordinal));
|
||
|
||
if (moved && !assignment.Transferred)
|
||
{
|
||
var source = ResolveSourcePose(spec);
|
||
assignment.FromPosition = source.Position;
|
||
assignment.FromRotation = source.Rotation;
|
||
assignment.FromScale = source.Scale;
|
||
card.PlaceImmediate(
|
||
assignment.FromPosition,
|
||
assignment.FromRotation,
|
||
assignment.FromScale);
|
||
}
|
||
|
||
card.Bind(spec.Ref);
|
||
card.SetArt(spec.Art.Front, spec.Art.Accent);
|
||
card.SetPose(
|
||
spec.Position,
|
||
spec.BaseRotation,
|
||
spec.FaceUp,
|
||
spec.Rested,
|
||
immediateAll,
|
||
spec.DamageLayout,
|
||
spec.Scale * Mathf.Max(0.05f, _config.cardScale));
|
||
|
||
if (moved && animateMoves)
|
||
{
|
||
var important = spec.Ref.Zone is
|
||
BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop;
|
||
var duration = important
|
||
? _animationConfig != null
|
||
? _animationConfig.importantMoveDuration
|
||
: 0.9f
|
||
: _animationConfig != null
|
||
? _animationConfig.moveDuration
|
||
: 0.5f;
|
||
var arc = _animationConfig != null
|
||
? _animationConfig.arcHeight
|
||
: 0.45f;
|
||
_moveAnimator.Enqueue(
|
||
card,
|
||
assignment.FromPosition,
|
||
assignment.FromRotation,
|
||
assignment.FromScale,
|
||
duration,
|
||
arc);
|
||
}
|
||
else if (moved && !animateMoves)
|
||
{
|
||
Debug.LogWarning(
|
||
"[GameBoard3D] Local render moved card "
|
||
+ spec.Ref
|
||
+ " without animation.");
|
||
}
|
||
|
||
card.SetHighlight(spec.Highlight);
|
||
card.SetStats(spec.Stats, spec.StatsModified);
|
||
|
||
if (spec.IsHand)
|
||
{
|
||
_handBindings.Add(new HandCardBinding(
|
||
card,
|
||
spec.HandIndex,
|
||
spec.HandCount));
|
||
}
|
||
}
|
||
|
||
UpdatePiles(view);
|
||
UpdateSlots(state);
|
||
UpdateAttackArrowState(view);
|
||
_hasRendered = true;
|
||
|
||
if (_inputLock != null
|
||
&& _animationConfig != null
|
||
&& _boardLockReleaseAt <= 0f
|
||
&& AnyCardAnimating())
|
||
{
|
||
_inputLock.Acquire("board");
|
||
_boardLockReleaseAt = Time.unscaledTime
|
||
+ Mathf.Max(1f, _animationConfig.cardMoveDuration * 3f)
|
||
+ _animationConfig.inputLockTail;
|
||
}
|
||
}
|
||
public void Clear()
|
||
{
|
||
_moveAnimator?.CancelAll(snapCards: false);
|
||
ClearCards();
|
||
_handBindings.Clear();
|
||
_view = null;
|
||
_previousView = null;
|
||
_sourceByInstance = null;
|
||
_hasRendered = false;
|
||
_arrowDirector?.HideImmediate();
|
||
ReleaseBoardLock();
|
||
}
|
||
|
||
private void UpdateAttackArrowState(MatchView view)
|
||
{
|
||
if (_arrowDirector == null)
|
||
return;
|
||
|
||
if (view?.Battle == null
|
||
|| !TryResolveAttackPoints(view, out var from, out var to))
|
||
{
|
||
_arrowDirector.HideAll();
|
||
return;
|
||
}
|
||
|
||
_arrowDirector.SetAttack(from, to);
|
||
UpdateBoostArrows(view);
|
||
}
|
||
|
||
private void UpdateAttackArrowPoints()
|
||
{
|
||
if (_arrowDirector == null || _view?.Battle == null)
|
||
return;
|
||
|
||
if (TryResolveAttackPoints(_view, out var from, out var to))
|
||
_arrowDirector.SetAttack(from, to);
|
||
|
||
UpdateBoostArrows(_view);
|
||
}
|
||
|
||
private void UpdateBoostArrows(MatchView view)
|
||
{
|
||
if (_arrowDirector == null || view?.Battle == null)
|
||
return;
|
||
|
||
var battle = view.Battle;
|
||
var boosts = new List<(Vector3 From, Vector3 To)>();
|
||
if (battle.BoosterSlot >= 0)
|
||
{
|
||
var attacker = BattleCard(
|
||
view,
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot);
|
||
var booster = BattleCard(
|
||
view,
|
||
battle.AttackerPlayer,
|
||
battle.BoosterSlot);
|
||
if (attacker != null && booster != null)
|
||
{
|
||
var boosterCenter = ArrowAnchor(
|
||
BoardCardRef.Field(
|
||
battle.AttackerPlayer,
|
||
battle.BoosterSlot,
|
||
booster),
|
||
battle.AttackerPlayer,
|
||
battle.BoosterSlot);
|
||
var attackerCenter = ArrowAnchor(
|
||
BoardCardRef.Field(
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot,
|
||
attacker),
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot);
|
||
boosts.Add((
|
||
CardEdge(boosterCenter, attackerCenter),
|
||
CardEdge(attackerCenter, boosterCenter)));
|
||
}
|
||
}
|
||
|
||
_arrowDirector.SetBoosts(boosts);
|
||
}
|
||
|
||
private bool TryResolveAttackPoints(
|
||
MatchView view,
|
||
out Vector3 from,
|
||
out Vector3 to)
|
||
{
|
||
from = default;
|
||
to = default;
|
||
var battle = view?.Battle;
|
||
if (battle == null)
|
||
return false;
|
||
|
||
var attacker = BattleCard(view, battle.AttackerPlayer, battle.AttackerSlot);
|
||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||
var defender = BattleCard(view, defenderIndex, battle.TargetSlot);
|
||
if (attacker == null || defender == null)
|
||
return false;
|
||
|
||
var attackerCenter = ArrowAnchor(
|
||
BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker),
|
||
battle.AttackerPlayer,
|
||
battle.AttackerSlot);
|
||
var defenderCenter = ArrowAnchor(
|
||
BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender),
|
||
defenderIndex,
|
||
battle.TargetSlot);
|
||
from = CardEdge(attackerCenter, defenderCenter);
|
||
to = CardEdge(defenderCenter, attackerCenter);
|
||
return true;
|
||
}
|
||
|
||
private Vector3 ArrowAnchor(BoardCardRef reference, int player, int slot)
|
||
{
|
||
var card = FindCard(reference);
|
||
if (card != null)
|
||
return card.transform.localPosition + (Vector3.up * 0.2f);
|
||
|
||
return _layout.CirclePositionForPlayer(player, _selfPlayer, slot)
|
||
+ (Vector3.up * 0.2f);
|
||
}
|
||
|
||
private Vector3 CardEdge(Vector3 center, Vector3 toward)
|
||
{
|
||
var direction = toward - center;
|
||
direction.y = 0f;
|
||
if (direction.sqrMagnitude < 0.0001f)
|
||
return center;
|
||
|
||
direction.Normalize();
|
||
var radius = Mathf.Max(
|
||
_config.cardWidth,
|
||
_config.cardHeight) * 0.5f * _config.cardScale;
|
||
return center + (direction * radius);
|
||
}
|
||
|
||
private CardView BattleCard(MatchView view, int playerIndex, int slot)
|
||
{
|
||
var player = view?.Players.FirstOrDefault(item => item.Index == playerIndex);
|
||
if (player == null)
|
||
return null;
|
||
|
||
if (slot < 0)
|
||
return player.Vanguard;
|
||
|
||
return player.RearGuards != null
|
||
&& slot < player.RearGuards.Length
|
||
? player.RearGuards[slot]
|
||
: null;
|
||
}
|
||
|
||
private Card3D FindCard(BoardCardRef reference)
|
||
{
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card != null && card.Ref.Equals(reference))
|
||
return card;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private void BuildTable()
|
||
{
|
||
var width = (_config.boardHalfWidth * 2f) + 2.2f;
|
||
var depth = (_config.boardHalfLength * 2f) + 1.7f;
|
||
CreateBox(
|
||
"Table",
|
||
new Vector3(0f, -0.06f, 0f),
|
||
new Vector3(width, 0.12f, depth),
|
||
_config.tableColor);
|
||
|
||
var matWidth = (_config.boardHalfWidth * 2f) - 0.45f;
|
||
var matDepth = _config.boardHalfLength - 0.35f;
|
||
|
||
CreateBox(
|
||
"Mat Self",
|
||
new Vector3(0f, 0.002f, -(_config.boardHalfLength * 0.5f) - 0.05f),
|
||
new Vector3(matWidth, 0.008f, matDepth),
|
||
_config.matSelfColor);
|
||
CreateBox(
|
||
"Mat Opponent",
|
||
new Vector3(0f, 0.002f, (_config.boardHalfLength * 0.5f) + 0.05f),
|
||
new Vector3(matWidth, 0.008f, matDepth),
|
||
_config.matOpponentColor);
|
||
|
||
var divider = BoardMaterials.CreateUnlit(
|
||
null,
|
||
new Color(0.34f, 0.24f, 0.10f));
|
||
CreateBox(
|
||
"Center Line",
|
||
new Vector3(0f, 0.011f, 0f),
|
||
new Vector3(width - 0.3f, 0.012f, 0.055f),
|
||
divider);
|
||
|
||
var railMaterial = BoardMaterials.CreateUnlit(
|
||
null,
|
||
new Color(0.40f, 0.29f, 0.11f));
|
||
CreateBox(
|
||
"Rail Left",
|
||
new Vector3(-(_config.boardHalfWidth + 1.02f), 0.04f, 0f),
|
||
new Vector3(0.07f, 0.08f, depth),
|
||
railMaterial);
|
||
CreateBox(
|
||
"Rail Right",
|
||
new Vector3(_config.boardHalfWidth + 1.02f, 0.04f, 0f),
|
||
new Vector3(0.07f, 0.08f, depth),
|
||
railMaterial);
|
||
CreateBox(
|
||
"Rail Near",
|
||
new Vector3(0f, 0.04f, -(_config.boardHalfLength + 0.79f)),
|
||
new Vector3(width, 0.08f, 0.07f),
|
||
railMaterial);
|
||
CreateBox(
|
||
"Rail Far",
|
||
new Vector3(0f, 0.04f, _config.boardHalfLength + 0.79f),
|
||
new Vector3(width, 0.08f, 0.07f),
|
||
railMaterial);
|
||
}
|
||
|
||
private void BuildSlots()
|
||
{
|
||
_slotsRoot = new GameObject("Slots").transform;
|
||
_slotsRoot.SetParent(transform, false);
|
||
|
||
for (var localSeat = 0; localSeat < 2; localSeat++)
|
||
{
|
||
for (var slot = PlaymatLayout.VanguardSlot;
|
||
slot < PlaymatLayout.RearGuardCount;
|
||
slot++)
|
||
{
|
||
var view = BoardSlotView.Create(
|
||
_slotsRoot,
|
||
_config,
|
||
localSeat,
|
||
LocalToPlayer(localSeat),
|
||
slot,
|
||
_layout);
|
||
_slots.Add(view);
|
||
_slotByKey[view.Key] = view;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void BuildZones()
|
||
{
|
||
_zonesRoot = new GameObject("Zones").transform;
|
||
_zonesRoot.SetParent(transform, false);
|
||
|
||
var guardianDepth = (_config.guardianRowGap * 2f) + 0.8f;
|
||
_guardianZone = BoardZoneView.Create(
|
||
_zonesRoot,
|
||
"Guardian Zone",
|
||
new Vector3(0f, 0.012f, 0f),
|
||
_config.guardianMaxWidth + 1.4f,
|
||
guardianDepth,
|
||
Mathf.Max(0.022f, _config.cardWidth * 0.04f),
|
||
_config.guardianColor,
|
||
null,
|
||
0f,
|
||
-(guardianDepth * 0.5f) - 0.2f,
|
||
new Color(0.72f, 0.92f, 1f, 0.75f),
|
||
_config.highlightColor);
|
||
|
||
for (var localSeat = 0; localSeat < 2; localSeat++)
|
||
{
|
||
var zSign = _layout.ZSign(localSeat);
|
||
var sideSign = localSeat == 0 ? -1f : 1f;
|
||
var zone = BoardZoneView.Create(
|
||
_zonesRoot,
|
||
"Damage Zone " + localSeat,
|
||
new Vector3(
|
||
sideSign * (_config.damageX + 0.02f),
|
||
0.011f,
|
||
zSign * (_config.frontRowZ + 1.3f)),
|
||
1.15f,
|
||
2.6f,
|
||
Mathf.Max(0.018f, _config.cardWidth * 0.03f),
|
||
_config.damageColor,
|
||
null,
|
||
0f,
|
||
-zSign * 1.32f,
|
||
new Color(1f, 0.78f, 0.76f, 0.75f),
|
||
_config.highlightColor);
|
||
_damageZones[localSeat] = zone;
|
||
|
||
var triggerPosition = _layout.TriggerPosition(localSeat, 0, 1);
|
||
_triggerZones[localSeat] = BoardZoneView.Create(
|
||
_zonesRoot,
|
||
"Trigger Zone " + localSeat,
|
||
new Vector3(triggerPosition.x, 0.011f, triggerPosition.z),
|
||
1.25f,
|
||
1.05f,
|
||
Mathf.Max(0.016f, _config.cardWidth * 0.025f),
|
||
new Color(0.30f, 0.18f, 0.52f),
|
||
_config.showZoneLabels ? "TRIGGER" : string.Empty,
|
||
0.04f,
|
||
zSign * 0.72f,
|
||
new Color(0.82f, 0.76f, 1f, 0.75f),
|
||
_config.highlightColor);
|
||
}
|
||
}
|
||
|
||
private void BuildPiles()
|
||
{
|
||
_pilesRoot = new GameObject("Piles").transform;
|
||
_pilesRoot.SetParent(transform, false);
|
||
|
||
_deckSelf = CreatePile(
|
||
BoardZone.Deck, 0, 0,
|
||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||
new Color(0.20f, 0.34f, 0.60f));
|
||
_deckOpponent = CreatePile(
|
||
BoardZone.Deck, 1, 1,
|
||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||
new Color(0.60f, 0.26f, 0.26f));
|
||
_dropSelf = CreatePile(
|
||
BoardZone.Drop, 0, 0,
|
||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||
new Color(0.30f, 0.30f, 0.40f));
|
||
_dropOpponent = CreatePile(
|
||
BoardZone.Drop, 1, 1,
|
||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||
new Color(0.40f, 0.28f, 0.28f));
|
||
}
|
||
|
||
private Pile3D CreatePile(
|
||
BoardZone zone,
|
||
int player,
|
||
int localSeat,
|
||
string title,
|
||
Color accent)
|
||
{
|
||
var position = zone switch
|
||
{
|
||
BoardZone.Deck => _layout.DeckPosition(localSeat),
|
||
BoardZone.Drop => _layout.DropPosition(localSeat),
|
||
_ => _layout.SoulPosition(localSeat),
|
||
};
|
||
|
||
var pile = Pile3D.Create(
|
||
_pilesRoot,
|
||
_config,
|
||
zone,
|
||
player,
|
||
position,
|
||
title,
|
||
accent);
|
||
_piles.Add(pile);
|
||
return pile;
|
||
}
|
||
|
||
private void BuildDropIndicator()
|
||
{
|
||
_dropIndicator = BoardZoneView.Create(
|
||
transform,
|
||
"Drop Indicator",
|
||
new Vector3(0f, 0.014f, 0f),
|
||
_config.cardWidth * 1.4f,
|
||
_config.cardHeight * 1.4f,
|
||
0.025f,
|
||
_config.highlightColor,
|
||
null,
|
||
0.1f,
|
||
0f,
|
||
Color.white,
|
||
_config.selectedColor);
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
}
|
||
|
||
private void BuildAttackArrow()
|
||
{
|
||
_arrowDirector = new ArrowDirector(
|
||
transform,
|
||
_animationConfig,
|
||
ArrowConfig.LoadOrDefault());
|
||
}
|
||
|
||
private void EnsureCamera()
|
||
{
|
||
if (_cameraDirector != null && _cameraDirector.IsActive)
|
||
return;
|
||
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
if (_camera == null)
|
||
return;
|
||
|
||
_camera.transform.position = _config.cameraPosition;
|
||
var direction = _config.cameraTarget - _config.cameraPosition;
|
||
if (direction.sqrMagnitude > 0.0001f)
|
||
_camera.transform.rotation = Quaternion.LookRotation(direction, Vector3.up);
|
||
_camera.fieldOfView = _config.cameraFov;
|
||
}
|
||
|
||
private void BuildSpecs(
|
||
MatchView view,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
foreach (var player in view.Players)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(player.Index, _selfPlayer);
|
||
AddFieldSpecs(player, localSeat, state, specs);
|
||
AddGuardianSpecs(view, player, localSeat, specs);
|
||
AddDamageSpecs(player, localSeat, state, specs);
|
||
AddTriggerSpecs(player, localSeat, specs);
|
||
}
|
||
|
||
AddHandSpecs(view, state, specs);
|
||
AddOpponentHandSpecs(view, specs);
|
||
}
|
||
|
||
private void AddFieldSpecs(
|
||
PlayerView player,
|
||
int localSeat,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
AddFieldCard(player, localSeat, PlaymatLayout.VanguardSlot, player.Vanguard, state, specs);
|
||
|
||
if (player.RearGuards == null)
|
||
return;
|
||
|
||
for (var slot = 0; slot < player.RearGuards.Length; slot++)
|
||
AddFieldCard(player, localSeat, slot, player.RearGuards[slot], state, specs);
|
||
}
|
||
|
||
private void AddFieldCard(
|
||
PlayerView player,
|
||
int localSeat,
|
||
int slot,
|
||
CardView card,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
if (card == null)
|
||
return;
|
||
|
||
var key = new BoardSlotKey(player.Index, slot);
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "F:" + player.Index + ":" + slot,
|
||
Ref = BoardCardRef.Field(player.Index, slot, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = _layout.CirclePosition(localSeat, slot),
|
||
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
|
||
// Карты в кругах V/R всегда открыты, независимо от снимка.
|
||
FaceUp = true,
|
||
Rested = card.Rested,
|
||
Stats = "P" + card.Power + " C" + card.Critical,
|
||
StatsModified = card.Power != card.BasePower
|
||
|| card.Critical != card.BaseCritical,
|
||
Highlight = state.SelectedHighlights.Contains(key)
|
||
? _config.selectedColor
|
||
: state.Highlights.Contains(key)
|
||
? _config.highlightColor
|
||
: (Color?)null,
|
||
});
|
||
}
|
||
|
||
private void AddGuardianSpecs(
|
||
MatchView view,
|
||
PlayerView player,
|
||
int localSeat,
|
||
List<CardSpec> specs)
|
||
{
|
||
var guardians = player.Guardian ?? new List<CardView>();
|
||
var battleCards = view.Battle != null && view.Battle.AttackerPlayer != player.Index
|
||
? view.Battle.GuardCards ?? new List<CardView>()
|
||
: new List<CardView>();
|
||
|
||
// Guardian and battle guard lists contain the same cards after a
|
||
// guard command; render each physical instance only once.
|
||
var cards = guardians
|
||
.Concat(battleCards)
|
||
.GroupBy(card => card.InstanceId)
|
||
.Select(group => group.First())
|
||
.ToList();
|
||
var total = cards.Count;
|
||
if (total == 0)
|
||
return;
|
||
|
||
for (var i = 0; i < total; i++)
|
||
{
|
||
var card = cards[i];
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "G:" + player.Index + ":" + card.InstanceId,
|
||
Ref = BoardCardRef.Guardian(player.Index, i, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = _layout.GuardianPosition(localSeat, i, total),
|
||
BaseRotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat) + 90f,
|
||
0f),
|
||
FaceUp = true,
|
||
Rested = false,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void AddDamageSpecs(
|
||
PlayerView player,
|
||
int localSeat,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
var faceUp = player.FaceUpDamage ?? new List<CardView>();
|
||
var total = Mathf.Min(
|
||
PlaymatLayout.DamageSlots,
|
||
faceUp.Count + Mathf.Max(0, player.FaceDownDamage));
|
||
|
||
for (var i = 0; i < total; i++)
|
||
{
|
||
var isUp = i < faceUp.Count;
|
||
var card = isUp ? faceUp[i] : null;
|
||
var highlight = DamageHighlight(state, player.Index, i);
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "D:" + player.Index + ":" + i,
|
||
Ref = BoardCardRef.Damage(player.Index, i, card, isUp),
|
||
Art = isUp
|
||
? ResolveArt(card.PrintingId)
|
||
: BoardArt.Fallback(_config.damageColor),
|
||
Position = _layout.DamagePosition(localSeat, i),
|
||
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
|
||
FaceUp = isUp,
|
||
Rested = true,
|
||
DamageLayout = true,
|
||
Highlight = highlight,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void AddTriggerSpecs(
|
||
PlayerView player,
|
||
int localSeat,
|
||
List<CardSpec> specs)
|
||
{
|
||
var triggers = player.Trigger ?? new List<CardView>();
|
||
if (triggers.Count == 0)
|
||
return;
|
||
|
||
var count = Mathf.Min(3, triggers.Count);
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var card = triggers[i];
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "T:" + player.Index + ":" + card.InstanceId,
|
||
Ref = BoardCardRef.Trigger(player.Index, i, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = _layout.TriggerPosition(localSeat, i, count),
|
||
BaseRotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat)
|
||
+ (localSeat == 0 ? 90f : -90f),
|
||
0f),
|
||
FaceUp = true,
|
||
Rested = false,
|
||
});
|
||
}
|
||
}
|
||
|
||
private Color? DamageHighlight(
|
||
BoardRenderState state,
|
||
int player,
|
||
int index)
|
||
{
|
||
var damageChoice = state.DamageChoice
|
||
&& player == state.DamageChoicePlayer;
|
||
var abilityCost = state.AbilityDamageTargeting
|
||
&& player == state.AbilityDamagePlayer;
|
||
if (!damageChoice && !abilityCost)
|
||
return null;
|
||
|
||
if (abilityCost
|
||
&& (state.AbilityDamageIndex == index
|
||
|| state.AbilityDamageIndexes.Contains(index)))
|
||
{
|
||
return _config.selectedColor;
|
||
}
|
||
|
||
return _config.highlightColor;
|
||
}
|
||
|
||
private void AddHandSpecs(
|
||
MatchView view,
|
||
BoardRenderState state,
|
||
List<CardSpec> specs)
|
||
{
|
||
var self = view.Players.FirstOrDefault(player => player.Index == view.You);
|
||
if (self?.Hand == null)
|
||
return;
|
||
|
||
var count = self.Hand.Count;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var card = self.Hand[i];
|
||
GetHandViewPose(i, count, out var position, out var rotation, out var scale);
|
||
Color? highlight = null;
|
||
if (state.SelectedHandInstanceIds.Contains(card.InstanceId))
|
||
highlight = _config.selectedColor;
|
||
else if (state.AvailableHandInstanceIds.Contains(card.InstanceId)
|
||
|| state.MulliganSelecting)
|
||
highlight = _config.highlightColor;
|
||
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "H:" + card.InstanceId,
|
||
Ref = BoardCardRef.Hand(self.Index, i, card),
|
||
Art = ResolveArt(card.PrintingId),
|
||
Position = position,
|
||
BaseRotation = rotation,
|
||
FaceUp = true,
|
||
Rested = false,
|
||
Scale = scale,
|
||
IsHand = true,
|
||
HandIndex = i,
|
||
HandCount = count,
|
||
Highlight = highlight,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Рука висит в поле зрения камеры снизу по центру и всегда смотрит
|
||
/// на игрока. При отсутствии камеры используется старый настольный
|
||
/// вариант — для EditMode-проверок.
|
||
/// </summary>
|
||
private void GetHandViewPose(
|
||
int index,
|
||
int count,
|
||
out Vector3 position,
|
||
out Quaternion rotation,
|
||
out float scale)
|
||
{
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
|
||
if (_camera == null)
|
||
{
|
||
var fallback = _layout.GetHandPose(index, count);
|
||
position = fallback.Position;
|
||
rotation = Quaternion.Euler(_config.handTilt, fallback.Yaw, 0f);
|
||
scale = 1f;
|
||
return;
|
||
}
|
||
|
||
var safeCount = Mathf.Max(1, count);
|
||
var step = _config.handSpacing;
|
||
if (safeCount > 1)
|
||
step = Mathf.Min(step, _config.handViewMaxWidth / (safeCount - 1));
|
||
|
||
var offset = index - (safeCount - 1) * 0.5f;
|
||
var halfHeight = Mathf.Tan(
|
||
_camera.fieldOfView * 0.5f * Mathf.Deg2Rad) * _config.handViewDistance;
|
||
var halfWidth = halfHeight * Mathf.Max(0.1f, _camera.aspect);
|
||
var x = ((_handViewportX - 0.5f) * 2f * halfWidth)
|
||
+ (offset * step);
|
||
var y = (_config.handViewportY - 0.5f) * 2f * halfHeight
|
||
+ (1f - Mathf.Abs(offset) / safeCount) * _config.handViewArc;
|
||
var z = _config.handViewDistance
|
||
+ Mathf.Abs(offset) * _config.handViewArc;
|
||
|
||
position = _camera.transform.TransformPoint(new Vector3(x, y, z));
|
||
var roll = -offset * _config.handViewRoll;
|
||
rotation = _camera.transform.rotation
|
||
* Quaternion.Euler(0f, 0f, roll)
|
||
* Quaternion.Euler(-90f, 0f, 0f);
|
||
scale = _config.handViewScale;
|
||
}
|
||
|
||
private void UpdateHandView()
|
||
{
|
||
if (_handBindings.Count == 0)
|
||
return;
|
||
|
||
if (_camera == null)
|
||
{
|
||
_camera = Camera.main;
|
||
if (_camera == null)
|
||
return;
|
||
}
|
||
|
||
foreach (var binding in _handBindings)
|
||
{
|
||
if (binding.Card == null
|
||
|| binding.Card.IsDragging
|
||
|| binding.Card.IsMoving)
|
||
continue;
|
||
|
||
GetHandViewPose(
|
||
binding.Index,
|
||
binding.Count,
|
||
out var position,
|
||
out var rotation,
|
||
out var scale);
|
||
binding.Card.SetPose(
|
||
position,
|
||
rotation,
|
||
true,
|
||
false,
|
||
false,
|
||
false,
|
||
scale * Mathf.Max(0.05f, _config.cardScale));
|
||
}
|
||
}
|
||
|
||
private void AddOpponentHandSpecs(MatchView view, List<CardSpec> specs)
|
||
{
|
||
if (!_config.opponentHandEnabled)
|
||
return;
|
||
|
||
var opponent = view.Players.FirstOrDefault(player => player.Index != view.You);
|
||
if (opponent == null || opponent.HandCount <= 0)
|
||
return;
|
||
|
||
var count = opponent.HandCount;
|
||
for (var i = 0; i < count; i++)
|
||
{
|
||
var pose = _layout.GetOpponentHandPose(i, count);
|
||
specs.Add(new CardSpec
|
||
{
|
||
Key = "OH:" + opponent.Index + ":" + i,
|
||
Ref = BoardCardRef.Hand(opponent.Index, i, null),
|
||
Art = BoardArt.Fallback(_config.matOpponentColor),
|
||
Position = pose.Position,
|
||
BaseRotation = Quaternion.Euler(-90f, 0f, pose.Yaw),
|
||
FaceUp = false,
|
||
Rested = false,
|
||
Scale = _config.opponentHandScale,
|
||
});
|
||
}
|
||
}
|
||
|
||
private void UpdatePiles(MatchView view)
|
||
{
|
||
var self = view.Players.FirstOrDefault(player => player.Index == view.You);
|
||
var opponent = view.Players.FirstOrDefault(player => player.Index != view.You);
|
||
|
||
UpdatePileSide(self, _deckSelf, _dropSelf);
|
||
UpdatePileSide(opponent, _deckOpponent, _dropOpponent);
|
||
|
||
_damageZones[0]?.SetLabel(string.Empty);
|
||
_damageZones[1]?.SetLabel(string.Empty);
|
||
|
||
if (_guardianZone != null)
|
||
_guardianZone.SetLabel(string.Empty);
|
||
}
|
||
|
||
private void UpdatePileSide(
|
||
PlayerView player,
|
||
Pile3D deck,
|
||
Pile3D drop)
|
||
{
|
||
if (deck == null)
|
||
return;
|
||
|
||
if (player == null)
|
||
{
|
||
deck.SetCount(0, null, _config.showZoneLabels ? "DECK" : string.Empty, Color.white);
|
||
drop.SetCount(0, null, _config.showZoneLabels ? "DROP" : string.Empty, Color.white);
|
||
return;
|
||
}
|
||
|
||
deck.SetCount(
|
||
player.DeckCount,
|
||
null,
|
||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||
new Color(0.20f, 0.34f, 0.60f));
|
||
|
||
var topDrop = player.Drop != null && player.Drop.Count > 0
|
||
? player.Drop[player.Drop.Count - 1]
|
||
: null;
|
||
drop.SetCount(
|
||
player.Drop?.Count ?? 0,
|
||
topDrop != null
|
||
? (_config.dropZoneFaceDown
|
||
? BoardTextures.Back
|
||
: ResolveArt(topDrop.PrintingId).Front)
|
||
: null,
|
||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||
new Color(0.30f, 0.30f, 0.40f));
|
||
}
|
||
|
||
private void UpdateSlots(BoardRenderState state)
|
||
{
|
||
_activeHighlights.Clear();
|
||
foreach (var key in state.Highlights)
|
||
_activeHighlights.Add(key);
|
||
|
||
_selectedHighlights.Clear();
|
||
foreach (var key in state.SelectedHighlights)
|
||
_selectedHighlights.Add(key);
|
||
|
||
_validDropSlots.Clear();
|
||
foreach (var key in state.DropTargets)
|
||
_validDropSlots.Add(key);
|
||
_guardianDropAllowed = state.GuardianDropAllowed;
|
||
|
||
ApplySlotHighlights();
|
||
}
|
||
|
||
private void ApplySlotHighlights()
|
||
{
|
||
foreach (var slot in _slots)
|
||
{
|
||
var selected = _selectedHighlights.Contains(slot.Key);
|
||
var highlighted = selected
|
||
|| _activeHighlights.Contains(slot.Key)
|
||
|| (_dragging && _validDropSlots.Contains(slot.Key));
|
||
slot.SetHighlighted(
|
||
highlighted,
|
||
selected ? _config.selectedColor : _config.highlightColor);
|
||
}
|
||
|
||
if (_guardianZone != null)
|
||
{
|
||
_guardianZone.SetHighlighted(_dragging && _guardianDropAllowed);
|
||
}
|
||
}
|
||
|
||
private void Update()
|
||
{
|
||
if (_boardLockReleaseAt > 0f
|
||
&& (!AnyCardAnimating()
|
||
|| Time.unscaledTime >= _boardLockReleaseAt))
|
||
{
|
||
_boardLockReleaseAt = -1f;
|
||
_inputLock?.Release("board");
|
||
}
|
||
|
||
UpdateHandView();
|
||
|
||
var delta = Time.unscaledDeltaTime;
|
||
_moveAnimator?.Tick(delta);
|
||
_cameraDirector?.Tick(delta);
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card != null)
|
||
card.Tick(delta);
|
||
}
|
||
|
||
_arrowDirector?.Tick(delta);
|
||
UpdateAttackArrowPoints();
|
||
|
||
if (_inputLock != null && _inputLock.IsLocked)
|
||
{
|
||
if (_mode != PointerMode.None || _dragging)
|
||
CancelPointer();
|
||
return;
|
||
}
|
||
|
||
HandleRightClick();
|
||
HandlePointer();
|
||
}
|
||
|
||
private void HandleRightClick()
|
||
{
|
||
var mouse = Mouse.current;
|
||
if (mouse == null
|
||
|| !mouse.rightButton.wasPressedThisFrame
|
||
|| IsPointerOverUi())
|
||
{
|
||
return;
|
||
}
|
||
|
||
var ray = GetRay(mouse.position.ReadValue());
|
||
if (!TryRaycast(ray, out var card, out _, out var pile))
|
||
return;
|
||
|
||
if (card != null)
|
||
{
|
||
CardRightClicked?.Invoke(card.Ref);
|
||
return;
|
||
}
|
||
|
||
if (pile != null)
|
||
PileRightClicked?.Invoke(pile.Ref);
|
||
}
|
||
|
||
private void HandlePointer()
|
||
{
|
||
if (!_hasRendered && _view == null)
|
||
return;
|
||
|
||
if (!TryReadPointer(out var pointer, out var pressed, out var held, out var released))
|
||
return;
|
||
|
||
if (pressed)
|
||
OnPointerPressed(pointer);
|
||
else if (held && _mode == PointerMode.Card && !_dragging)
|
||
OnPointerHeld(pointer);
|
||
else if (released)
|
||
OnPointerReleased(pointer);
|
||
}
|
||
|
||
private static bool TryReadPointer(
|
||
out Vector2 pointer,
|
||
out bool pressed,
|
||
out bool held,
|
||
out bool released)
|
||
{
|
||
pointer = default;
|
||
pressed = false;
|
||
held = false;
|
||
released = false;
|
||
|
||
var mouse = Mouse.current;
|
||
if (mouse != null)
|
||
{
|
||
pointer = mouse.position.ReadValue();
|
||
pressed = mouse.leftButton.wasPressedThisFrame;
|
||
held = mouse.leftButton.isPressed;
|
||
released = mouse.leftButton.wasReleasedThisFrame;
|
||
return true;
|
||
}
|
||
|
||
var touch = Touchscreen.current;
|
||
if (touch == null)
|
||
return false;
|
||
|
||
var primary = touch.primaryTouch;
|
||
pointer = primary.position.ReadValue();
|
||
pressed = primary.press.wasPressedThisFrame;
|
||
held = primary.press.isPressed;
|
||
released = primary.press.wasReleasedThisFrame;
|
||
return true;
|
||
}
|
||
|
||
private void OnPointerPressed(Vector2 pointer)
|
||
{
|
||
CancelPointer();
|
||
if (IsPointerOverUi())
|
||
return;
|
||
|
||
var ray = GetRay(pointer);
|
||
if (!TryRaycast(ray, out var card, out var slot, out var pile))
|
||
return;
|
||
|
||
_pressPosition = pointer;
|
||
if (card != null)
|
||
{
|
||
_mode = PointerMode.Card;
|
||
_pressedCard = card;
|
||
_pressedCardRef = card.Ref;
|
||
return;
|
||
}
|
||
|
||
if (slot != null)
|
||
{
|
||
_mode = PointerMode.Slot;
|
||
_pressedSlot = slot;
|
||
return;
|
||
}
|
||
|
||
if (pile != null)
|
||
{
|
||
_mode = PointerMode.Pile;
|
||
_pressedPile = pile;
|
||
}
|
||
}
|
||
|
||
private void OnPointerHeld(Vector2 pointer)
|
||
{
|
||
if (_pressedCard == null || _pressedCardRef.IsEmpty)
|
||
return;
|
||
|
||
if (_pressedCardRef.Player != _selfPlayer)
|
||
return;
|
||
|
||
if (!_pressedCardRef.IsField && !_pressedCardRef.IsHand)
|
||
return;
|
||
|
||
if (Vector2.Distance(pointer, _pressPosition)
|
||
< Mathf.Max(2f, _config.dragThresholdPixels))
|
||
return;
|
||
|
||
_dragging = true;
|
||
_pressedCard.BeginDrag();
|
||
ApplySlotHighlights();
|
||
UpdateDrag(pointer);
|
||
}
|
||
|
||
private void OnPointerReleased(Vector2 pointer)
|
||
{
|
||
if (_dragging)
|
||
{
|
||
UpdateDrag(pointer);
|
||
FinishDrag(pointer);
|
||
return;
|
||
}
|
||
|
||
if (_mode == PointerMode.Card && _pressedCard != null)
|
||
{
|
||
CardClicked?.Invoke(_pressedCardRef);
|
||
}
|
||
else if (_mode == PointerMode.Slot && _pressedSlot != null)
|
||
{
|
||
SlotClicked?.Invoke(_pressedSlot.Ref);
|
||
}
|
||
else if (_mode == PointerMode.Pile && _pressedPile != null)
|
||
{
|
||
PileClicked?.Invoke(_pressedPile.Ref);
|
||
}
|
||
|
||
CancelPointer();
|
||
}
|
||
|
||
private void UpdateDrag(Vector2 pointer)
|
||
{
|
||
if (!_dragging || _pressedCard == null)
|
||
return;
|
||
|
||
var ray = GetRay(pointer);
|
||
var plane = new Plane(Vector3.up, new Vector3(0f, _config.cardLift, 0f));
|
||
if (!plane.Raycast(ray, out var enter))
|
||
return;
|
||
|
||
var point = ray.GetPoint(enter);
|
||
point.x = Mathf.Clamp(point.x, -_config.boardHalfWidth, _config.boardHalfWidth);
|
||
point.z = Mathf.Clamp(point.z, -_config.boardHalfLength, _config.boardHalfLength);
|
||
_pressedCard.DragTo(point);
|
||
|
||
if (_layout.TryFindDropTarget(point, _selfPlayer, out var target))
|
||
{
|
||
_dragTarget = target;
|
||
ShowDropIndicator(target);
|
||
var key = new BoardSlotKey(target.Player, target.Slot);
|
||
var valid = target.Zone == BoardZone.Guardian
|
||
? _guardianDropAllowed
|
||
: _validDropSlots.Contains(key);
|
||
_dropIndicator.SetColor(valid ? _config.selectedColor : _config.damageColor);
|
||
}
|
||
else
|
||
{
|
||
_dragTarget = null;
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
}
|
||
}
|
||
|
||
private void FinishDrag(Vector2 pointer)
|
||
{
|
||
var card = _pressedCard;
|
||
var source = _pressedCardRef;
|
||
var target = _dragTarget;
|
||
_dragging = false;
|
||
_dragTarget = null;
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
|
||
if (card == null)
|
||
{
|
||
CancelPointer();
|
||
return;
|
||
}
|
||
|
||
if (IsPointerOverUi() || !target.HasValue)
|
||
{
|
||
card.ReturnToOrigin();
|
||
CancelPointer();
|
||
return;
|
||
}
|
||
|
||
var targetValue = target.Value;
|
||
var targetRef = targetValue.ToRef();
|
||
|
||
if (source.IsHand)
|
||
_handBindings.RemoveAll(binding => binding.Card == card);
|
||
|
||
// Атака — это клик по цели, карта остаётся на своём круге.
|
||
if ((source.IsField && targetValue.Player != source.Player)
|
||
|| (source.IsHand && targetValue.Player != source.Player
|
||
&& targetValue.Zone != BoardZone.Guardian))
|
||
{
|
||
card.ReturnToOrigin();
|
||
}
|
||
else
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(targetValue.Player, _selfPlayer);
|
||
var position = targetValue.Zone == BoardZone.Guardian
|
||
? _layout.GuardianPosition(localSeat, 0, 1)
|
||
: _layout.CirclePosition(localSeat, targetValue.Slot);
|
||
var restYaw = !source.IsHand && card.IsRested ? 90f : 0f;
|
||
var rotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat) + restYaw,
|
||
0f);
|
||
card.DropAt(position, rotation);
|
||
}
|
||
|
||
CardDropped?.Invoke(source, targetRef);
|
||
CancelPointer();
|
||
}
|
||
|
||
private void ShowDropIndicator(BoardDropTarget target)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(target.Player, _selfPlayer);
|
||
var position = target.Zone == BoardZone.Guardian
|
||
? _layout.GuardianPosition(localSeat, 0, 1)
|
||
: _layout.CirclePosition(localSeat, target.Slot);
|
||
_dropIndicator.transform.localPosition = new Vector3(
|
||
position.x,
|
||
0.014f,
|
||
position.z);
|
||
_dropIndicator.gameObject.SetActive(true);
|
||
}
|
||
|
||
private bool TryRaycast(
|
||
Ray ray,
|
||
out Card3D card,
|
||
out BoardSlotView slot,
|
||
out Pile3D pile)
|
||
{
|
||
card = null;
|
||
slot = null;
|
||
pile = null;
|
||
|
||
var hits = Physics.RaycastAll(ray, 100f, ~0, QueryTriggerInteraction.Ignore);
|
||
if (hits.Length == 0)
|
||
return false;
|
||
|
||
Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
|
||
foreach (var hit in hits)
|
||
{
|
||
if (hit.collider == null)
|
||
continue;
|
||
|
||
card = hit.collider.GetComponentInParent<Card3D>();
|
||
if (card != null && card.gameObject.activeInHierarchy)
|
||
return true;
|
||
|
||
slot = hit.collider.GetComponentInParent<BoardSlotView>();
|
||
if (slot != null)
|
||
return true;
|
||
|
||
pile = hit.collider.GetComponentInParent<Pile3D>();
|
||
if (pile != null)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private Ray GetRay(Vector2 screenPoint)
|
||
{
|
||
if (_camera == null)
|
||
_camera = Camera.main;
|
||
return _camera != null
|
||
? _camera.ScreenPointToRay(screenPoint)
|
||
: new Ray(Vector3.up * 10f, Vector3.down);
|
||
}
|
||
|
||
private static bool IsPointerOverUi()
|
||
{
|
||
var eventSystem = EventSystem.current;
|
||
return eventSystem != null && eventSystem.IsPointerOverGameObject();
|
||
}
|
||
|
||
private Card3D RentCard()
|
||
{
|
||
while (_pool.Count > 0)
|
||
{
|
||
var pooled = _pool.Pop();
|
||
if (pooled == null)
|
||
continue;
|
||
pooled.gameObject.SetActive(true);
|
||
return ConfigureCardSpeeds(pooled);
|
||
}
|
||
|
||
if (_config.cardPrefab != null)
|
||
{
|
||
var instance = Instantiate(_config.cardPrefab, _cardsRoot);
|
||
return ConfigureCardSpeeds(instance);
|
||
}
|
||
|
||
if (_cardsRoot == null)
|
||
{
|
||
_cardsRoot = new GameObject("Cards").transform;
|
||
_cardsRoot.SetParent(transform, false);
|
||
}
|
||
|
||
return ConfigureCardSpeeds(Card3D.Create(_cardsRoot, _config));
|
||
}
|
||
|
||
private Card3D ConfigureCardSpeeds(Card3D card)
|
||
{
|
||
if (card != null && _animationConfig != null)
|
||
{
|
||
card.SetAnimationSpeeds(
|
||
_animationConfig.cardMoveSpeed,
|
||
_animationConfig.cardRotateSpeed);
|
||
}
|
||
|
||
return card;
|
||
}
|
||
|
||
private void ReleaseCard(Card3D card)
|
||
{
|
||
if (card == null)
|
||
return;
|
||
|
||
_moveAnimator?.CancelCard(card);
|
||
card.SetHighlight(null);
|
||
card.gameObject.SetActive(false);
|
||
_pool.Push(card);
|
||
}
|
||
|
||
private void ClearCards()
|
||
{
|
||
foreach (var card in _cards.Values)
|
||
ReleaseCard(card);
|
||
_cards.Clear();
|
||
}
|
||
|
||
private void CancelPointer()
|
||
{
|
||
if (_pressedCard != null && _dragging)
|
||
_pressedCard.ReturnToOrigin();
|
||
_mode = PointerMode.None;
|
||
_pressedCard = null;
|
||
_pressedSlot = null;
|
||
_pressedPile = null;
|
||
_dragging = false;
|
||
_dragTarget = null;
|
||
if (_dropIndicator != null)
|
||
_dropIndicator.gameObject.SetActive(false);
|
||
ApplySlotHighlights();
|
||
}
|
||
|
||
private (Vector3 Position, Quaternion Rotation, Vector3 Scale) ResolveSourcePose(
|
||
CardSpec spec)
|
||
{
|
||
var localSeat = _layout.ToLocalSeat(
|
||
spec.Ref.Player,
|
||
_selfPlayer);
|
||
var baseYaw = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat),
|
||
0f);
|
||
var scale = Vector3.one * Mathf.Max(
|
||
0.05f,
|
||
spec.Scale * _config.cardScale);
|
||
|
||
if (spec.Ref.InstanceId != 0
|
||
&& _sourceByInstance != null
|
||
&& _sourceByInstance.TryGetValue(
|
||
spec.Ref.InstanceId,
|
||
out var pose))
|
||
{
|
||
return (pose.Position, pose.Rotation, scale);
|
||
}
|
||
|
||
// Fallback: карта пришла из колоды — начинаем рубашкой вверх.
|
||
var position = _layout.DeckPosition(localSeat);
|
||
var rotation = baseYaw * Quaternion.Euler(180f, 0f, 0f);
|
||
return (position, rotation, scale);
|
||
}
|
||
|
||
private Dictionary<int, SourcePose> BuildSourceMap()
|
||
{
|
||
var map = new Dictionary<int, SourcePose>();
|
||
if (_view == null)
|
||
return map;
|
||
|
||
foreach (var player in _view.Players)
|
||
{
|
||
if (player == null)
|
||
continue;
|
||
|
||
var localSeat = _layout.ToLocalSeat(player.Index, _selfPlayer);
|
||
var baseYaw = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat),
|
||
0f);
|
||
var cardScale = Vector3.one * _config.cardScale;
|
||
|
||
if (player.Soul != null)
|
||
{
|
||
var position = _layout.SoulPosition(localSeat);
|
||
foreach (var card in player.Soul)
|
||
{
|
||
if (card != null && card.InstanceId != 0)
|
||
map[card.InstanceId] = new SourcePose(
|
||
position,
|
||
baseYaw,
|
||
cardScale);
|
||
}
|
||
}
|
||
|
||
if (player.Drop != null)
|
||
{
|
||
var position = _layout.DropPosition(localSeat);
|
||
foreach (var card in player.Drop)
|
||
{
|
||
if (card != null && card.InstanceId != 0)
|
||
map[card.InstanceId] = new SourcePose(
|
||
position,
|
||
baseYaw,
|
||
cardScale);
|
||
}
|
||
}
|
||
|
||
if (player.FaceUpDamage != null)
|
||
{
|
||
for (var i = 0; i < player.FaceUpDamage.Count; i++)
|
||
{
|
||
var card = player.FaceUpDamage[i];
|
||
if (card == null || card.InstanceId == 0)
|
||
continue;
|
||
|
||
var position = _layout.DamagePosition(localSeat, i);
|
||
var rotation = Quaternion.Euler(
|
||
0f,
|
||
_layout.BaseYaw(localSeat) - 90f,
|
||
0f);
|
||
map[card.InstanceId] = new SourcePose(
|
||
position,
|
||
rotation,
|
||
cardScale);
|
||
}
|
||
}
|
||
}
|
||
|
||
return map;
|
||
}
|
||
|
||
private static bool CanReuseByKey(Card3D existing, CardSpec spec)
|
||
{
|
||
// Анонимные карты (InstanceId == 0) привязаны к ключу зоны/индекса.
|
||
// Если и старая, и новая карты реальны и разные — это замена,
|
||
// а не переворот/сдвиг, и нужна полноценная анимация из источника.
|
||
if (existing.Ref.InstanceId == 0 || spec.Ref.InstanceId == 0)
|
||
return true;
|
||
|
||
return existing.Ref.InstanceId == spec.Ref.InstanceId;
|
||
}
|
||
|
||
private BoardArt ResolveArt(string printingId)
|
||
{
|
||
if (ArtProvider != null)
|
||
{
|
||
var art = ArtProvider(printingId);
|
||
if (art != null)
|
||
return art;
|
||
}
|
||
|
||
return BoardArt.Fallback(BoardTextures.AccentFor(printingId));
|
||
}
|
||
|
||
private int LocalToPlayer(int localSeat) =>
|
||
_layout.ToPlayer(localSeat, _selfPlayer);
|
||
|
||
private GameObject CreateBox(
|
||
string name,
|
||
Vector3 position,
|
||
Vector3 scale,
|
||
Color color)
|
||
{
|
||
return CreateBox(name, position, scale, BoardMaterials.CreateLit(color));
|
||
}
|
||
|
||
private GameObject CreateBox(
|
||
string name,
|
||
Vector3 position,
|
||
Vector3 scale,
|
||
Material material)
|
||
{
|
||
var box = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||
box.name = name;
|
||
box.transform.SetParent(transform, false);
|
||
box.transform.localPosition = position;
|
||
box.transform.localScale = scale;
|
||
|
||
var collider = box.GetComponent<Collider>();
|
||
if (collider != null)
|
||
{
|
||
if (Application.isPlaying)
|
||
Destroy(collider);
|
||
else
|
||
DestroyImmediate(collider);
|
||
}
|
||
|
||
var renderer = box.GetComponent<MeshRenderer>();
|
||
renderer.sharedMaterial = material;
|
||
BoardMaterials.SetShadows(renderer, false);
|
||
return box;
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
if (_instance == this)
|
||
_instance = null;
|
||
|
||
_moveAnimator?.CancelAll(snapCards: false);
|
||
_arrowDirector?.Dispose();
|
||
_cameraDirector?.Restore();
|
||
ReleaseBoardLock();
|
||
}
|
||
|
||
private void ReleaseBoardLock()
|
||
{
|
||
if (_boardLockReleaseAt <= 0f)
|
||
return;
|
||
|
||
_boardLockReleaseAt = -1f;
|
||
_inputLock?.Release("board");
|
||
}
|
||
|
||
private bool AnyCardAnimating()
|
||
{
|
||
if (_moveAnimator != null && _moveAnimator.IsBusy)
|
||
return true;
|
||
|
||
foreach (var card in _cards.Values)
|
||
{
|
||
if (card != null && card.IsAnimating)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private sealed class HandCardBinding
|
||
{
|
||
public HandCardBinding(Card3D card, int index, int count)
|
||
{
|
||
Card = card;
|
||
Index = index;
|
||
Count = count;
|
||
}
|
||
|
||
public Card3D Card { get; }
|
||
public int Index { get; }
|
||
public int Count { get; }
|
||
}
|
||
|
||
private sealed class CardAssignment
|
||
{
|
||
public Card3D Card;
|
||
public bool Transferred;
|
||
public string OldKey;
|
||
public Vector3 FromPosition;
|
||
public Quaternion FromRotation;
|
||
public Vector3 FromScale;
|
||
}
|
||
|
||
private readonly struct SourcePose
|
||
{
|
||
public readonly Vector3 Position;
|
||
public readonly Quaternion Rotation;
|
||
public readonly Vector3 Scale;
|
||
|
||
public SourcePose(Vector3 position, Quaternion rotation, Vector3 scale)
|
||
{
|
||
Position = position;
|
||
Rotation = rotation;
|
||
Scale = scale;
|
||
}
|
||
}
|
||
|
||
private sealed class CardSpec
|
||
{
|
||
public string Key;
|
||
public BoardCardRef Ref;
|
||
public BoardArt Art;
|
||
public Vector3 Position;
|
||
public Quaternion BaseRotation;
|
||
public bool FaceUp;
|
||
public bool Rested;
|
||
public bool DamageLayout;
|
||
public float Scale = 1f;
|
||
public bool IsHand;
|
||
public int HandIndex;
|
||
public int HandCount;
|
||
public string Stats;
|
||
public bool StatsModified;
|
||
public Color? Highlight;
|
||
}
|
||
}
|
||
} |