1328 lines
44 KiB
C#
1328 lines
44 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.InputSystem;
|
|
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 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<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);
|
|
_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<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);
|
|
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<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), 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<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.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<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 = ((_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<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, "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<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 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<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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|