VanguardReMaster/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs

1454 lines
56 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using Vanguard.Battle;
using Vanguard.Board3D;
using Vanguard.Shared.Protocol;
namespace Vanguard.Tests
{
public sealed class GameBoard3DTests
{
[Test]
public void Board_Shows_Attack_Arrow_For_Active_Battle()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
view.Battle = new BattleView
{
AttackerPlayer = 0,
AttackerSlot = 3,
TargetSlot = -1,
GuardShield = 5000,
};
board.Render(view, new BoardRenderState());
Assert.IsTrue(board.AttackArrowVisible);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Board_Renders_Field_Hand_Guardian_Damage_And_Soul()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var cards = ActiveCards();
Assert.GreaterOrEqual(cards.Count, 9);
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& card.Ref.Slot == 3));
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Guardian
&& card.Ref.InstanceId == 7));
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0));
var opponentHand = cards
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 1)
.ToList();
Assert.AreEqual(3, opponentHand.Count);
Assert.IsTrue(opponentHand.All(card => !card.IsFaceUp));
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0));
var rested = cards.Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& card.Ref.Slot == 3);
Assert.IsTrue(rested.IsRested);
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f);
Assert.AreEqual("\u25B28000", rested.StatsText);
var hiddenDamage = cards.Single(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0
&& card.Ref.Index == 1);
Assert.IsFalse(hiddenDamage.IsFaceUp);
Assert.IsTrue(hiddenDamage.IsRested);
var visibleDamage = cards.Single(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0
&& card.Ref.Index == 0);
Assert.IsTrue(visibleDamage.IsFaceUp);
Assert.AreEqual(
-90f,
Mathf.DeltaAngle(0f, visibleDamage.TargetYaw),
0.1f,
"Damage cards must rest counter-clockwise");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Guardian_And_Battle_Guard_Cards_Are_Not_Duplicated()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == 0);
view.Battle = new BattleView
{
AttackerPlayer = 1,
AttackerSlot = 3,
TargetSlot = -1,
GuardCards = self.Guardian,
};
board.Render(view, new BoardRenderState());
var guardianCards = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Guardian
&& card.Ref.Player == 0
&& card.Ref.InstanceId == 7)
.ToList();
Assert.AreEqual(
1,
guardianCards.Count,
"A guard card present in both Guardian and Battle.GuardCards must render once");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Hand_Is_Anchored_To_Camera_Bottom_Center_And_Faces_Player()
{
var config = PlaymatLayoutConfig.CreateDefault();
var cameraGo = new GameObject("Main Camera", typeof(Camera))
{
tag = "MainCamera",
};
var camera = cameraGo.GetComponent<Camera>();
camera.fieldOfView = config.cameraFov;
camera.transform.position = config.cameraPosition;
camera.transform.rotation = Quaternion.LookRotation(
config.cameraTarget - config.cameraPosition,
Vector3.up);
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var hand = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0)
.ToList();
Assert.AreEqual(2, hand.Count);
foreach (var card in hand)
{
var viewport = camera.WorldToViewportPoint(card.transform.position);
Assert.Greater(viewport.z, 0f, "Hand card must be in front of the camera");
Assert.That(viewport.x, Is.InRange(0.15f, 0.85f));
Assert.That(viewport.y, Is.InRange(0.01f, 0.55f));
var toCamera = (camera.transform.position - card.transform.position).normalized;
Assert.Greater(
Vector3.Dot(card.transform.up, toCamera),
0.8f,
"Hand card must face the camera");
}
}
finally
{
Object.DestroyImmediate(board.gameObject);
Object.DestroyImmediate(cameraGo);
}
}
[Test]
public void Hands_Fill_Mirrored_And_Newest_Is_On_Top()
{
var config = PlaymatLayoutConfig.CreateDefault();
var cameraGo = new GameObject("Main Camera", typeof(Camera))
{
tag = "MainCamera",
};
var camera = cameraGo.GetComponent<Camera>();
camera.fieldOfView = config.cameraFov;
camera.transform.position = config.cameraPosition;
camera.transform.rotation = Quaternion.LookRotation(
config.cameraTarget - config.cameraPosition,
Vector3.up);
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var player = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0)
.OrderBy(card => card.Ref.Index)
.ToList();
var opponent = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 1)
.OrderBy(card => card.Ref.Index)
.ToList();
Assert.AreEqual(2, player.Count);
Assert.AreEqual(3, opponent.Count);
var playerOldest = camera.WorldToViewportPoint(
player[0].transform.position);
var playerNewest = camera.WorldToViewportPoint(
player[1].transform.position);
Assert.Less(
playerOldest.x,
playerNewest.x,
"Player hand fills left-to-right: the newest card is on the right");
Assert.Less(
playerNewest.z,
playerOldest.z,
"Player newest card must be closer to the camera");
var opponentOldest = camera.WorldToViewportPoint(
opponent[0].transform.position);
var opponentNewest = camera.WorldToViewportPoint(
opponent[2].transform.position);
Assert.Greater(
opponentOldest.x,
opponentNewest.x,
"Opponent hand fills right-to-left: the newest card is on the left");
Assert.Less(
opponentNewest.z,
opponentOldest.z,
"Opponent newest card must be closer to the camera");
var view = BuildView(you: 0);
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
view.Players[1].HandCount = 4;
board.Render(view, new BoardRenderState(), animateMoves: false);
var newPlayer = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 2);
var newOpponent = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 1
&& card.Ref.Index == 3);
Assert.Greater(
camera.WorldToViewportPoint(newPlayer.TargetPosition).x,
playerNewest.x,
"A newly drawn player card must appear to the right");
Assert.Less(
camera.WorldToViewportPoint(newOpponent.TargetPosition).x,
opponentNewest.x,
"A newly drawn opponent card must appear to the left");
var drawSource = board.Layout.DeckPosition(0)
+ (Vector3.up * 0.03f);
Assert.Less(
Vector3.Distance(newPlayer.transform.position, drawSource),
0.01f,
"A drawn card must start above the deck");
Assert.AreEqual(
config.cardScale,
newPlayer.transform.localScale.x,
0.01f,
"A drawn card must start at deck scale, not hand scale");
}
finally
{
Object.DestroyImmediate(board.gameObject);
Object.DestroyImmediate(cameraGo);
}
}
[Test]
public void Hand_Reflows_While_A_Card_Leaves_From_Hand()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
var leaving = new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
};
view.Players[0].Hand.Add(leaving);
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldestBefore = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
var next = BuildView(you: 0);
next.Players[0].RearGuards[3] = leaving;
board.Render(next, new BoardRenderState());
var oldestAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestBefore, oldestAfter),
0.01f,
"The hand must start reflowing when a card leaves");
Assert.GreaterOrEqual(
board.MoveAnimator.PendingCount,
1,
"The leaving card must still be queued for movement");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Dropped_Hand_Card_Holds_At_Drop_Until_Returned()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var card = ActiveCards().Single(item =>
item.Ref.Zone == BoardZone.Hand
&& item.Ref.Player == 0
&& item.Ref.Index == 0);
var handTarget = card.TargetPosition;
card.BeginDrag();
card.DragTo(new Vector3(1f, 0f, 1f));
var held = card.transform.position;
card.EndDragAtCurrent();
Assert.IsFalse(card.IsDragging);
Assert.Less(
Vector3.Distance(card.TargetPosition, held),
0.001f,
"Dropped card must stay at the drop point");
card.Tick(0.2f);
Assert.Less(
Vector3.Distance(card.transform.position, held),
0.001f,
"Dropped card must not twitch while waiting for a snapshot");
var pendingField = typeof(GameBoard3D).GetField(
"_dropPendingCard",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.IsNotNull(pendingField);
pendingField.SetValue(board, card);
board.ReturnDroppedCard();
Assert.Less(
Vector3.Distance(card.TargetPosition, handTarget),
0.001f,
"Returned card must target its hand pose again");
card.Tick(0.5f);
Assert.Less(
Vector3.Distance(card.transform.position, handTarget),
0.05f);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Hand_Hover_Lifts_Card_By_Ten_Percent()
{
var config = PlaymatLayoutConfig.CreateDefault();
var cameraGo = new GameObject("Main Camera", typeof(Camera))
{
tag = "MainCamera",
};
var camera = cameraGo.GetComponent<Camera>();
camera.fieldOfView = config.cameraFov;
camera.transform.position = config.cameraPosition;
camera.transform.rotation = Quaternion.LookRotation(
config.cameraTarget - config.cameraPosition,
Vector3.up);
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var handCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0);
var before = handCard.TargetPosition;
var hoverField = typeof(GameBoard3D).GetField(
"_hoveredHandCard",
BindingFlags.Instance | BindingFlags.NonPublic);
var updateHand = typeof(GameBoard3D).GetMethod(
"UpdateHandView",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.IsNotNull(hoverField);
Assert.IsNotNull(updateHand);
hoverField.SetValue(board, handCard);
updateHand.Invoke(board, null);
var lifted = handCard.TargetPosition;
var delta = lifted - before;
Assert.Greater(delta.magnitude, 0.01f);
Assert.Greater(
Vector3.Dot(delta.normalized, camera.transform.up),
0.9f,
"Hover lift must go up in camera space");
hoverField.SetValue(board, null);
updateHand.Invoke(board, null);
Assert.Less(
Vector3.Distance(handCard.TargetPosition, before),
0.001f,
"Unhover must return the card to its hand pose");
}
finally
{
Object.DestroyImmediate(board.gameObject);
Object.DestroyImmediate(cameraGo);
}
}
[Test]
public void Hand_Draw_Stages_New_Cards_One_By_One()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldestTargetBefore = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
var next = BuildView(you: 0);
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/009EN",
InstanceId = 14,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.Render(next, new BoardRenderState());
Assert.IsTrue(board.StagedDrawHandActive);
Assert.AreEqual(2, board.StagedDrawCards.Count);
Assert.AreEqual(2, board.MoveAnimator.PendingCount);
Assert.AreEqual(2, ActiveCards().Count(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0));
// Повторный snapshot без новых доборов не должен сбрасывать
// уже начатую ступенчатую раздачу.
board.Render(next, new BoardRenderState());
Assert.IsTrue(board.StagedDrawHandActive);
Assert.AreEqual(2, board.StagedDrawCards.Count);
var oldestTargetAtDrawStart = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Less(
Vector3.Distance(
oldestTargetBefore,
oldestTargetAtDrawStart),
0.001f,
"The hand must not reflow before the first draw arrives");
board.MoveAnimator.Tick(0f);
Assert.AreEqual(1, board.MoveAnimator.ActiveCount);
var firstDrawn = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 2);
firstDrawn.Tick(0.7f);
board.MoveAnimator.Tick(0f);
Assert.AreEqual(3, board.StagedDrawCards.Count);
Assert.IsTrue(board.StagedDrawHandActive);
var oldestTargetAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestTargetBefore, oldestTargetAfter),
0.01f,
"The hand must reflow after the first drawn card arrives");
Assert.AreEqual(3, ActiveCards().Count(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0));
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Mulligan_Remaining_Cards_Reflow_After_Each_Return()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/009EN",
InstanceId = 14,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldestTargetBefore = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
var next = BuildView(you: 0);
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/010EN",
InstanceId = 15,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/011EN",
InstanceId = 16,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.QueueHandReturnToDeck(0, 2);
board.Render(next, new BoardRenderState());
Assert.IsTrue(board.StagedReturnHandActive);
Assert.AreEqual(4, board.StagedReturnCards.Count);
Assert.IsTrue(board.StagedDrawHandActive);
// Первый возврат.
board.MoveAnimator.Tick(0f);
board.ReturningCards[0].Tick(0.7f);
board.MoveAnimator.Tick(0f);
// Второй возврат.
board.MoveAnimator.Tick(0.3f);
board.ReturningCards[0].Tick(0.7f);
board.MoveAnimator.Tick(0f);
Assert.IsFalse(board.StagedReturnHandActive);
Assert.AreEqual(2, board.StagedReturnCards.Count);
Assert.IsTrue(board.StagedDrawHandActive);
var oldestTargetAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestTargetBefore, oldestTargetAfter),
0.01f,
"Remaining mulligan cards must reflow after returns");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Hand_Return_To_Deck_Starts_Right_To_Left_And_Targets_Deck_Bottom()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldHand = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0)
.ToDictionary(card => card.Ref.Index);
var source13 = oldHand[2].transform.position;
var source12 = oldHand[1].transform.position;
var oldestTargetBefore = oldHand[0].TargetPosition;
var next = BuildView(you: 0);
next.Players[0].Hand = new List<CardView>
{
next.Players[0].Hand[0],
};
board.QueueHandReturnToDeck(0, 2);
board.Render(next, new BoardRenderState());
Assert.AreEqual(2, board.ReturningCardCount);
var returning = board.ReturningCards;
Assert.AreEqual(2, returning[0].Ref.Index);
Assert.AreEqual(1, returning[1].Ref.Index);
Assert.Less(
Vector3.Distance(returning[0].transform.position, source13),
0.001f,
"The rightmost return card must wait in the hand until its turn");
Assert.Less(
Vector3.Distance(returning[1].transform.position, source12),
0.001f,
"The next return card must stay in the hand until the first finishes");
Assert.IsTrue(board.StagedReturnHandActive);
Assert.AreEqual(3, board.StagedReturnCards.Count);
var oldestAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Less(
Vector3.Distance(oldestTargetBefore, oldestAfter),
0.001f,
"The hand must not reflow before the first card leaves");
var deckBottom = board.Layout.DeckPosition(0)
- (Vector3.up * 0.04f);
Assert.IsTrue(board.TryGetHandReturnTarget(
returning[0],
out var returnTarget,
out var returnRotation));
Assert.Less(
Vector3.Distance(returnTarget, deckBottom),
0.001f);
Assert.Greater(
Vector3.Dot(returnRotation * Vector3.up, Vector3.down),
0.9f);
Assert.AreEqual(2, board.MoveAnimator.PendingCount);
board.MoveAnimator.Tick(0f);
Assert.IsTrue(returning[0].IsMoving);
returning[0].Tick(0.7f);
board.MoveAnimator.Tick(0f);
Assert.AreEqual(1, board.ReturningCardCount);
Assert.AreEqual(2, board.StagedReturnCards.Count);
var oldestAfterFirst = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestTargetBefore, oldestAfterFirst),
0.01f,
"The hand must reflow after the first card leaves");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void CardMoveAnimator_Runs_Sequential_Requests_And_Completes_Each()
{
var input = new InputLockService();
var animator = new CardMoveAnimator(input, AnimationConfig.Default);
var root = new GameObject("Root");
var cardA = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
var cardB = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
try
{
var from = new Vector3(-1f, 0.1f, 0f);
var to = new Vector3(1f, 0.1f, 0f);
cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardA.SetPose(to, Quaternion.identity, true, false, false);
cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardB.SetPose(to, Quaternion.identity, true, false, false);
var completed = new List<Card3D>();
animator.Enqueue(
cardA, from, Quaternion.identity, Vector3.one,
0.1f, 0.2f, sequential: true,
onCompleted: card => completed.Add(card));
animator.Enqueue(
cardB, from, Quaternion.identity, Vector3.one,
0.1f, 0.2f, sequential: true,
onCompleted: card => completed.Add(card));
animator.Tick(0f);
Assert.AreEqual(1, animator.ActiveCount);
Assert.AreEqual(1, animator.PendingCount);
cardA.Tick(0.2f);
animator.Tick(0f);
CollectionAssert.AreEqual(new[] { cardA }, completed);
Assert.AreEqual(0, animator.ActiveCount);
Assert.AreEqual(1, animator.PendingCount);
animator.Tick(0.3f);
Assert.AreEqual(1, animator.ActiveCount);
cardB.Tick(0.2f);
animator.Tick(0f);
CollectionAssert.AreEqual(new[] { cardA, cardB }, completed);
Assert.IsFalse(animator.IsBusy);
}
finally
{
animator.CancelAll();
Object.DestroyImmediate(root);
}
}
[Test]
public void Trigger_Zone_Uses_Local_Minus_And_Plus_Ninety_Degrees()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == 0);
var opponent = view.Players.Single(player => player.Index == 1);
self.Trigger = new List<CardView>
{
new CardView
{
PrintingId = "BT01/002EN",
InstanceId = 31,
FaceUp = true,
},
};
opponent.Trigger = new List<CardView>
{
new CardView
{
PrintingId = "BT01/003EN",
InstanceId = 32,
FaceUp = true,
},
};
board.Render(view, new BoardRenderState());
var own = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Trigger
&& card.Ref.Player == 0);
var enemy = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Trigger
&& card.Ref.Player == 1);
var layout = new PlaymatLayout(config);
Assert.AreEqual(
-90f,
Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw),
0.1f);
Assert.AreEqual(
90f,
Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw),
0.1f);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Pool_Reuses_Cards_Between_Snapshots()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
board.Render(view, new BoardRenderState());
var count = ActiveCards().Count;
board.Render(view, new BoardRenderState());
Assert.AreEqual(count, ActiveCards().Count);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Local_Seat_Is_Always_Bottom_Of_The_Board()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 1), new BoardRenderState());
var vanguard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Vanguard
&& card.Ref.Player == 1);
Assert.Less(vanguard.TargetPosition.z, 0f);
var opponent = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Vanguard
&& card.Ref.Player == 0);
Assert.Greater(opponent.TargetPosition.z, 0f);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Highlight_And_Selection_State_Is_Applied_To_Cards()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var state = new BoardRenderState();
state.Highlights.Add(new BoardSlotKey(0, 3));
state.SelectedHandInstanceIds.Add(12);
board.Render(BuildView(you: 0), state);
var rested = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& card.Ref.Slot == 3);
Assert.IsTrue(rested.transform.Find("Highlight").gameObject.activeSelf);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void CardMoveAnimator_Releases_Input_Lock_When_Card_Is_Cancelled()
{
var config = PlaymatLayoutConfig.CreateDefault();
var root = new GameObject("AnimatorRoot");
var card = Card3D.Create(root.transform, config);
var input = new InputLockService();
var animator = new CardMoveAnimator(input, AnimationConfig.Default);
try
{
var from = new Vector3(-1f, 0.1f, 0f);
var to = new Vector3(1f, 0.1f, 0f);
card.PlaceImmediate(from, Quaternion.identity, Vector3.one);
card.SetPose(to, Quaternion.identity, true, false, false);
animator.Enqueue(
card,
from,
Quaternion.identity,
Vector3.one,
0.5f,
0.3f);
animator.Tick(0.01f);
Assert.IsTrue(input.IsLocked);
animator.CancelCard(card);
Assert.IsFalse(input.IsLocked);
}
finally
{
animator.CancelAll();
Object.DestroyImmediate(root);
}
}
[Test]
public void Swapping_Rear_Guards_Does_Not_Release_Or_Duplicate_Cards()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == 0);
var first = new CardView
{
PrintingId = "BT01/002EN",
InstanceId = 41,
Power = 5000,
BasePower = 5000,
Critical = 1,
BaseCritical = 1,
};
var second = new CardView
{
PrintingId = "BT01/003EN",
InstanceId = 42,
Power = 5000,
BasePower = 5000,
Critical = 1,
BaseCritical = 1,
};
self.RearGuards[0] = first;
self.RearGuards[2] = second;
board.Render(view, new BoardRenderState());
self.RearGuards[0] = second;
self.RearGuards[2] = first;
board.Render(view, new BoardRenderState());
var cards = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& (card.Ref.InstanceId == 41
|| card.Ref.InstanceId == 42))
.ToList();
Assert.AreEqual(2, cards.Count);
Assert.AreEqual(
2,
cards.Select(card => card.Ref.InstanceId).Distinct().Count());
Assert.IsTrue(cards.All(card => card.gameObject.activeSelf));
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Card_Arc_Move_Interpolates_And_Finishes_At_Target()
{
var config = PlaymatLayoutConfig.CreateDefault();
var root = new GameObject("ArcRoot");
var card = Card3D.Create(root.transform, config);
try
{
var from = new Vector3(-1f, 0.1f, 0f);
var to = new Vector3(1f, 0.1f, 0f);
card.PlaceImmediate(from, Quaternion.identity, Vector3.one);
card.SetPose(
to,
Quaternion.identity,
true,
false,
false,
scale: 1f);
card.StartArcMove(
from,
Quaternion.identity,
Vector3.one,
0.5f,
0.4f);
card.Tick(0.25f);
Assert.IsTrue(card.IsMoving);
Assert.Greater(card.transform.position.y, from.y);
card.Tick(0.5f);
Assert.IsFalse(card.IsMoving);
Assert.Less(
Vector3.Distance(card.transform.position, to),
0.001f);
}
finally
{
Object.DestroyImmediate(root);
}
}
[Test]
public void Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
board.Render(view, new BoardRenderState());
board.MoveAnimator.Tick(0.01f);
var hidden = ActiveCards()
.Where(card =>
(card.Ref.Zone == BoardZone.Hand && card.Ref.Player != view.You)
|| (card.Ref.Zone == BoardZone.Damage && card.Ref.InstanceId == 0))
.ToList();
var hiddenCount = hidden.Count;
board.Render(view, new BoardRenderState());
var hiddenAfter = ActiveCards()
.Where(card =>
(card.Ref.Zone == BoardZone.Hand && card.Ref.Player != view.You)
|| (card.Ref.Zone == BoardZone.Damage && card.Ref.InstanceId == 0))
.ToList();
Assert.AreEqual(hiddenCount, hiddenAfter.Count);
Assert.AreEqual(
0,
board.MoveAnimator.PendingCount,
"Repeated render should not queue new moves for stable hidden cards");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Revealing_Face_Down_Damage_Reuses_Card_And_Stays_In_Damage_Zone()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == view.You);
board.Render(view, new BoardRenderState());
var hidden = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == view.You
&& card.Ref.Index == 1);
self.FaceUpDamage.Add(new CardView
{
PrintingId = "BT01/099EN",
InstanceId = 99,
FaceUp = true,
});
self.FaceDownDamage = 1;
board.Render(view, new BoardRenderState());
var revealed = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == view.You
&& card.Ref.Index == 1);
Assert.AreSame(hidden, revealed, "Revealed damage must reuse Card3D");
var layout = new PlaymatLayout(config);
var expected = layout.DamagePosition(0, 1);
Assert.Less(
Vector3.Distance(revealed.transform.position, expected),
0.5f,
"Revealed card should stay in damage zone, not fly from deck");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void New_Field_Unit_Starts_From_Hand_Not_Deck()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == view.You);
board.Render(view, new BoardRenderState());
var handCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == view.You
&& card.Ref.InstanceId == 11);
var handPosition = handCard.transform.position;
var cardView = self.Hand.First(card => card.InstanceId == 11);
self.Hand.Remove(cardView);
self.RearGuards[0] = cardView;
board.Render(view, new BoardRenderState());
var fieldCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == view.You
&& card.Ref.Slot == 0);
Assert.AreSame(handCard, fieldCard);
Assert.Less(
Vector3.Distance(fieldCard.transform.position, handPosition),
0.1f,
"New field unit should start at hand position");
Assert.Greater(
Vector3.Distance(fieldCard.transform.position, board.Layout.DeckPosition(0)),
0.5f,
"New field unit should not start at deck");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Removing_Card_From_Hand_Does_Not_Queue_Arcs_For_Remaining_Hand()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == view.You);
board.Render(view, new BoardRenderState());
var called = self.Hand.First(card => card.InstanceId == 11);
self.Hand.Remove(called);
self.RearGuards[0] = called;
board.Render(view, new BoardRenderState());
var remaining = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == view.You
&& card.Ref.InstanceId == 12);
Assert.IsFalse(
board.MoveAnimator.IsCardQueued(remaining),
"Remaining hand cards must reflow without arc requests");
Assert.IsFalse(
board.MoveAnimator.IsCardActive(remaining),
"Remaining hand cards must reflow without active arc requests");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Queued_Card_Holds_At_Source_Until_Arc_Starts()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == view.You);
board.Render(view, new BoardRenderState());
var first = self.Hand[0];
var second = self.Hand[1];
self.Hand.RemoveAt(1);
self.Hand.RemoveAt(0);
self.RearGuards[0] = first;
self.RearGuards[1] = second;
board.Render(view, new BoardRenderState());
board.MoveAnimator.Tick(0.001f);
var firstCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == view.You
&& card.Ref.Slot == 0);
var secondCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == view.You
&& card.Ref.Slot == 1);
Assert.IsTrue(firstCard.IsMoving || board.MoveAnimator.IsCardActive(firstCard),
"First card should already be moving");
Assert.IsTrue(board.MoveAnimator.IsCardQueued(secondCard),
"Second card should be queued behind the first");
Assert.Greater(
Vector3.Distance(secondCard.transform.position, secondCard.TargetPosition),
0.5f,
"Queued card must stay at source, not smooth-move toward target");
board.MoveAnimator.Tick(10f);
Assert.IsTrue(secondCard.IsMoving || board.MoveAnimator.IsCardActive(secondCard),
"Second card should start moving after the first finishes");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Cancel_Queued_Move_Releases_Hold()
{
var input = new InputLockService();
var animator = new CardMoveAnimator(input, AnimationConfig.Default);
var root = new GameObject("Root");
var cardA = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
var cardB = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
try
{
var from = new Vector3(-1f, 0.1f, 0f);
var to = new Vector3(1f, 0.1f, 0f);
cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardA.SetPose(to, Quaternion.identity, true, false, false);
animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardB.SetPose(to, Quaternion.identity, true, false, false);
animator.Enqueue(cardB, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
animator.Tick(0.001f);
Assert.IsTrue(animator.IsCardQueued(cardB));
animator.CancelCard(cardB);
Assert.IsFalse(animator.IsCardQueued(cardB));
cardB.Tick(0.5f);
Assert.Less(
Vector3.Distance(cardB.transform.position, cardB.TargetPosition),
0.5f,
"Canceled queued card should resume normal movement to target");
}
finally
{
animator.CancelAll();
Object.DestroyImmediate(root);
}
}
[Test]
public void Hand_Visibility_Toggles()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var hand = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0)
.ToList();
Assert.Greater(hand.Count, 0);
board.SetHandVisible(false);
Assert.IsTrue(hand.All(card => !card.gameObject.activeSelf),
"Hand cards should be hidden");
board.SetHandVisible(true);
Assert.IsTrue(hand.All(card => card.gameObject.activeSelf),
"Hand cards should be visible again");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void CardMoveAnimator_Coalesces_Requests_For_Same_Card()
{
var input = new InputLockService();
var animator = new CardMoveAnimator(input, AnimationConfig.Default);
var root = new GameObject("Root");
var cardA = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
var cardB = Card3D.Create(root.transform, PlaymatLayoutConfig.Default);
try
{
var from = new Vector3(-1f, 0.1f, 0f);
var to = new Vector3(1f, 0.1f, 0f);
cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardA.SetPose(to, Quaternion.identity, true, false, false);
animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
Assert.AreEqual(1, animator.PendingCount);
// Вторая команда той же карты должна заменить первую,
// а не добавиться в очередь.
animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.4f, 0.3f);
Assert.AreEqual(1, animator.PendingCount);
Assert.AreEqual(0, animator.ActiveCount);
// Другая карта — отдельный запрос.
cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardB.SetPose(to, Quaternion.identity, true, false, false);
animator.Enqueue(cardB, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
Assert.AreEqual(2, animator.PendingCount);
animator.CancelAll();
// Новый запрос во время активного движения отменяет старый
// и снова становится единственным.
cardA.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardA.SetPose(to, Quaternion.identity, true, false, false);
animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
animator.Tick(0.2f);
Assert.AreEqual(1, animator.ActiveCount);
animator.Enqueue(cardA, from, Quaternion.identity, Vector3.one, 0.5f, 0.3f);
Assert.AreEqual(1, animator.ActiveCount + animator.PendingCount);
}
finally
{
animator.CancelAll();
Object.DestroyImmediate(root);
}
}
private static List<Card3D> ActiveCards() =>
Resources.FindObjectsOfTypeAll<Card3D>()
.Where(card => card.gameObject.activeInHierarchy)
.ToList();
private static MatchView BuildView(int you)
{
CardView Card(string id, int instance, bool rested = false, bool faceUp = true) =>
new CardView
{
PrintingId = id,
InstanceId = instance,
Rested = rested,
FaceUp = faceUp,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
};
var self = new PlayerView
{
Index = you,
Name = "Self",
Vanguard = Card("BT01/001EN", 1),
RearGuards = new CardView[5],
Hand = new List<CardView>
{
Card("BT01/002EN", 11),
Card("BT01/003EN", 12),
},
Guardian = new List<CardView> { Card("BT01/004EN", 7) },
Soul = new List<CardView> { Card("BT01/005EN", 8) },
FaceUpDamage = new List<CardView> { Card("BT01/006EN", 9) },
FaceDownDamage = 2,
DeckCount = 40,
Drop = new List<CardView>(),
};
self.RearGuards[3] = Card("BT01/007EN", 5, rested: true);
var otherIndex = 1 - you;
var opponent = new PlayerView
{
Index = otherIndex,
Name = "Opponent",
Vanguard = Card("BT01/010EN", 21),
RearGuards = new CardView[5],
Hand = new List<CardView>(),
HandCount = 3,
Guardian = new List<CardView>(),
Soul = new List<CardView>(),
FaceUpDamage = new List<CardView>(),
FaceDownDamage = 0,
DeckCount = 40,
Drop = new List<CardView>(),
};
opponent.RearGuards[4] = Card("BT01/011EN", 22, rested: true);
return new MatchView
{
MatchId = "board-test",
You = you,
ActivePlayer = you,
Phase = GamePhase.Main,
Players = new List<PlayerView> { self, opponent },
Clock = new ClockView(),
LegalIntents = new List<IntentPayload>(),
};
}
}
}