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

1567 lines
52 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 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> _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 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 AttackArrowView _attackArrow;
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 =>
_attackArrow != null && _attackArrow.IsVisible;
public BoardArtProvider ArtProvider { get; set; }
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;
_attackArrow?.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();
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();
_attackArrow?.HideImmediate();
ReleaseBoardLock();
}
else
{
EnsureCamera();
}
gameObject.SetActive(visible);
}
public void Render(MatchView view, BoardRenderState state)
{
if (!_built)
return;
if (view == null)
{
ClearCards();
_view = null;
return;
}
SetSelfPlayer(view.You);
_view = view;
state ??= new BoardRenderState();
if (_camera == null)
_camera = Camera.main;
_handBindings.Clear();
var specs = new List<CardSpec>(40);
BuildSpecs(view, state, specs);
var used = new HashSet<string>(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 * Mathf.Max(0.05f, _config.cardScale));
card.SetHighlight(spec.Highlight);
card.SetStats(spec.Stats, spec.StatsModified);
if (spec.IsHand)
{
_handBindings.Add(new HandCardBinding(
card,
spec.HandIndex,
spec.HandCount));
}
}
if (_cards.Count > 0)
{
var stale = _cards
.Where(pair => !used.Contains(pair.Key))
.Select(pair => pair.Key)
.ToList();
foreach (var key in stale)
{
ReleaseCard(_cards[key]);
_cards.Remove(key);
}
}
UpdatePiles(view);
UpdateSlots(state);
UpdateAttackArrowState(view);
_hasRendered = true;
if (_inputLock != null
&& _animationConfig != null
&& AnyCardAnimating())
{
_inputLock.Acquire("board");
_boardLockReleaseAt = Time.unscaledTime
+ Mathf.Max(1f, _animationConfig.cardMoveDuration * 3f)
+ _animationConfig.inputLockTail;
}
}
public void Clear()
{
ClearCards();
_handBindings.Clear();
_view = null;
_hasRendered = false;
_attackArrow?.HideImmediate();
ReleaseBoardLock();
}
private void UpdateAttackArrowState(MatchView view)
{
if (_attackArrow == null)
return;
if (view?.Battle == null
|| !TryResolveAttackPoints(view, out var from, out var to))
{
_attackArrow.Hide();
return;
}
_attackArrow.Show(from, to);
}
private void UpdateAttackArrowPoints()
{
if (_attackArrow == null || !_attackArrow.IsVisible || _view?.Battle == null)
return;
if (TryResolveAttackPoints(_view, out var from, out var to))
_attackArrow.SetPoints(from, to);
}
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;
from = ArrowAnchor(
BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker),
battle.AttackerPlayer,
battle.AttackerSlot);
to = ArrowAnchor(
BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender),
defenderIndex,
battle.TargetSlot);
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 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()
{
_attackArrow = new AttackArrowView();
_attackArrow.Build(transform, _animationConfig);
}
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<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.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>();
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) + 90f,
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) + 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, 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),
0f),
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<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.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)
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 ? 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);
_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()
{
if (_boardLockReleaseAt > 0f
&& (!AnyCardAnimating()
|| Time.unscaledTime >= _boardLockReleaseAt))
{
_boardLockReleaseAt = -1f;
_inputLock?.Release("board");
}
UpdateHandView();
var delta = Time.unscaledDeltaTime;
foreach (var card in _cards.Values)
{
if (card != null)
card.Tick(delta);
}
_attackArrow?.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;
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<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;
_attackArrow?.Dispose();
ReleaseBoardLock();
}
private void ReleaseBoardLock()
{
if (_boardLockReleaseAt <= 0f)
return;
_boardLockReleaseAt = -1f;
_inputLock?.Release("board");
}
private bool AnyCardAnimating()
{
foreach (var card in _cards.Values)
{
if (card != null && card.IsAnimating)
return true;
}
return false;
}
private sealed class HandCardBinding
{
public HandCardBinding(Card3D card, int index, int count)
{
Card = card;
Index = index;
Count = count;
}
public Card3D Card { get; }
public int Index { get; }
public int Count { get; }
}
private sealed class CardSpec
{
public string Key;
public BoardCardRef Ref;
public BoardArt Art;
public Vector3 Position;
public Quaternion BaseRotation;
public bool FaceUp;
public bool Rested;
public bool DamageLayout;
public float Scale = 1f;
public bool IsHand;
public int HandIndex;
public int HandCount;
public string Stats;
public bool StatsModified;
public Color? Highlight;
}
}
}