client: TASK-2026-001 smooth card movement animation fixes
CI / test (push) Successful in 3m14s Details

- Stable identity for anonymous cards (opponent hand, face-down damage) via reuse-by-key.
- Source pose map from previous MatchView for Soul/Drop/FaceUpDamage; Deck fallback face-down.
- Coalesce CardMoveAnimator requests per card (cancel queued/active before new enqueue).
- Split snapshot/UI render: BattleView.Render(animateMoves), local calls use false.
- Skip SetPose in UpdateHandView for moving cards.
- Synchronize AnimationConfig.asset with new code fields.
- Add EditMode tests for hidden-card reuse, damage reveal, hand source, animator coalescing.
- Fix ClientSmokeTests reflection call for Render signature.

EditMode: 46/46; PLAYMAT_SMOKE success; SCENE_SMOKE success.
This commit is contained in:
MpakobecEx 2026-10-24 20:45:51 +05:00
parent aac0268cf2
commit 29822be2ad
9 changed files with 560 additions and 166 deletions

View File

@ -30,3 +30,16 @@ MonoBehaviour:
inputLockTail: 0.15 inputLockTail: 0.15
arrowShowDuration: 0.25 arrowShowDuration: 0.25
arrowHideDuration: 0.2 arrowHideDuration: 0.2
moveDuration: 0.5
importantMoveDuration: 0.9
moveDelayBetweenCards: 0.18
arcHeight: 0.45
cameraZoomDuration: 0.35
triggerRevealHold: 1.2
cameraReturnDuration: 0.4
cameraRezoomDuration: 0.3
growDuration: 0.35
dashScrollSpeed: 1.5
boostGrowDuration: 0.3
boostThickness: 0.8
arrowFadeDuration: 0.2

View File

@ -44,6 +44,37 @@ namespace Vanguard.Board3D
public bool IsBusy => _queue.Count > 0 || _active.Count > 0; public bool IsBusy => _queue.Count > 0 || _active.Count > 0;
public int PendingCount => _queue.Count;
public int ActiveCount => _active.Count;
public bool IsCardQueued(Card3D card)
{
if (card == null)
return false;
foreach (var request in _queue)
{
if (request.Card == card)
return true;
}
return false;
}
public bool IsCardActive(Card3D card)
{
if (card == null)
return false;
foreach (var active in _active)
{
if (active.Card == card)
return true;
}
return false;
}
public void SetConfig(AnimationConfig config) public void SetConfig(AnimationConfig config)
{ {
_config = config; _config = config;
@ -60,6 +91,10 @@ namespace Vanguard.Board3D
if (card == null || card.IsDragging) if (card == null || card.IsDragging)
return; return;
// У одной карты не может быть нескольких активных/ожидающих
// запросов одновременно: старый отменяем, новый стартует из
// текущей визуальной позиции.
CancelCard(card);
EnsureLock(); EnsureLock();
_queue.Enqueue(new Request _queue.Enqueue(new Request
{ {

View File

@ -40,6 +40,8 @@ namespace Vanguard.Board3D
private PlaymatLayout _layout; private PlaymatLayout _layout;
private float _handViewportX; private float _handViewportX;
private MatchView _view; private MatchView _view;
private MatchView _previousView;
private Dictionary<int, SourcePose> _sourceByInstance;
private int _selfPlayer; private int _selfPlayer;
private bool _hasRendered; private bool _hasRendered;
private bool _built; private bool _built;
@ -238,7 +240,7 @@ namespace Vanguard.Board3D
gameObject.SetActive(visible); gameObject.SetActive(visible);
} }
public void Render(MatchView view, BoardRenderState state) public void Render(MatchView view, BoardRenderState state, bool animateMoves = true)
{ {
if (!_built) if (!_built)
return; return;
@ -247,11 +249,18 @@ namespace Vanguard.Board3D
{ {
ClearCards(); ClearCards();
_view = null; _view = null;
_previousView = null;
_sourceByInstance = null;
return; return;
} }
SetSelfPlayer(view.You); SetSelfPlayer(view.You);
// Запоминаем старые позиции карт по InstanceId, пока не заменили _view.
_sourceByInstance = BuildSourceMap();
_previousView = _view;
_view = view; _view = view;
state ??= new BoardRenderState(); state ??= new BoardRenderState();
if (_camera == null) if (_camera == null)
_camera = Camera.main; _camera = Camera.main;
@ -284,9 +293,6 @@ namespace Vanguard.Board3D
var assignments = new Dictionary<string, CardAssignment>( var assignments = new Dictionary<string, CardAssignment>(
StringComparer.Ordinal); StringComparer.Ordinal);
// Сначала распределяем старые Card3D по InstanceId. Нельзя
// освобождать карту из занятого ключа: она может участвовать
// в обмене двух кругов и должна переехать в другой ключ.
foreach (var spec in specs) foreach (var spec in specs)
{ {
var transferred = false; var transferred = false;
@ -296,7 +302,27 @@ namespace Vanguard.Board3D
var fromScale = default(Vector3); var fromScale = default(Vector3);
Card3D card = null; Card3D card = null;
if (spec.Ref.InstanceId != 0 // 1) Переиспользуем анонимные карты по стабильному ключу:
// скрытая рука противника, закрытый урон и т.п.
if (_cards.TryGetValue(spec.Key, out var existing)
&& existing != null
&& !assignedCards.Contains(existing)
&& CanReuseByKey(existing, spec))
{
card = existing;
transferred = true;
oldKey = spec.Key;
fromPosition = card.transform.position;
fromRotation = card.transform.rotation;
fromScale = card.transform.localScale;
if (spec.Ref.InstanceId != 0)
assignedInstances.Add(spec.Ref.InstanceId);
if (existing.Ref.InstanceId != 0)
assignedInstances.Add(existing.Ref.InstanceId);
}
// 2) Перемещаем реальную карту по InstanceId.
else if (spec.Ref.InstanceId != 0
&& previousByInstance.TryGetValue( && previousByInstance.TryGetValue(
spec.Ref.InstanceId, spec.Ref.InstanceId,
out var previous) out var previous)
@ -351,12 +377,10 @@ namespace Vanguard.Board3D
if (moved && !assignment.Transferred) if (moved && !assignment.Transferred)
{ {
var source = SourcePose(spec); var source = ResolveSourcePose(spec);
assignment.FromPosition = source.Position; assignment.FromPosition = source.Position;
assignment.FromRotation = source.Rotation; assignment.FromRotation = source.Rotation;
assignment.FromScale = Vector3.one * Mathf.Max( assignment.FromScale = source.Scale;
0.05f,
spec.Scale * _config.cardScale);
card.PlaceImmediate( card.PlaceImmediate(
assignment.FromPosition, assignment.FromPosition,
assignment.FromRotation, assignment.FromRotation,
@ -374,7 +398,7 @@ namespace Vanguard.Board3D
spec.DamageLayout, spec.DamageLayout,
spec.Scale * Mathf.Max(0.05f, _config.cardScale)); spec.Scale * Mathf.Max(0.05f, _config.cardScale));
if (moved) if (moved && animateMoves)
{ {
var important = spec.Ref.Zone is var important = spec.Ref.Zone is
BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop; BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop;
@ -396,6 +420,13 @@ namespace Vanguard.Board3D
duration, duration,
arc); arc);
} }
else if (moved && !animateMoves)
{
Debug.LogWarning(
"[GameBoard3D] Local render moved card "
+ spec.Ref
+ " without animation.");
}
card.SetHighlight(spec.Highlight); card.SetHighlight(spec.Highlight);
card.SetStats(spec.Stats, spec.StatsModified); card.SetStats(spec.Stats, spec.StatsModified);
@ -431,6 +462,8 @@ namespace Vanguard.Board3D
ClearCards(); ClearCards();
_handBindings.Clear(); _handBindings.Clear();
_view = null; _view = null;
_previousView = null;
_sourceByInstance = null;
_hasRendered = false; _hasRendered = false;
_arrowDirector?.HideImmediate(); _arrowDirector?.HideImmediate();
ReleaseBoardLock(); ReleaseBoardLock();
@ -1111,7 +1144,9 @@ namespace Vanguard.Board3D
foreach (var binding in _handBindings) foreach (var binding in _handBindings)
{ {
if (binding.Card == null || binding.Card.IsDragging) if (binding.Card == null
|| binding.Card.IsDragging
|| binding.Card.IsMoving)
continue; continue;
GetHandViewPose( GetHandViewPose(
@ -1652,23 +1687,112 @@ namespace Vanguard.Board3D
ApplySlotHighlights(); ApplySlotHighlights();
} }
private (Vector3 Position, Quaternion Rotation, Vector3 Scale) SourcePose( private (Vector3 Position, Quaternion Rotation, Vector3 Scale) ResolveSourcePose(
CardSpec spec) CardSpec spec)
{ {
var localSeat = _layout.ToLocalSeat( var localSeat = _layout.ToLocalSeat(
spec.Ref.Player, spec.Ref.Player,
_selfPlayer); _selfPlayer);
var position = _layout.DeckPosition(localSeat); var baseYaw = Quaternion.Euler(
var rotation = Quaternion.Euler(
0f, 0f,
_layout.BaseYaw(localSeat), _layout.BaseYaw(localSeat),
0f); 0f);
return ( var scale = Vector3.one * Mathf.Max(
0.05f,
spec.Scale * _config.cardScale);
if (spec.Ref.InstanceId != 0
&& _sourceByInstance != null
&& _sourceByInstance.TryGetValue(
spec.Ref.InstanceId,
out var pose))
{
return (pose.Position, pose.Rotation, scale);
}
// Fallback: карта пришла из колоды — начинаем рубашкой вверх.
var position = _layout.DeckPosition(localSeat);
var rotation = baseYaw * Quaternion.Euler(180f, 0f, 0f);
return (position, rotation, scale);
}
private Dictionary<int, SourcePose> BuildSourceMap()
{
var map = new Dictionary<int, SourcePose>();
if (_view == null)
return map;
foreach (var player in _view.Players)
{
if (player == null)
continue;
var localSeat = _layout.ToLocalSeat(player.Index, _selfPlayer);
var baseYaw = Quaternion.Euler(
0f,
_layout.BaseYaw(localSeat),
0f);
var cardScale = Vector3.one * _config.cardScale;
if (player.Soul != null)
{
var position = _layout.SoulPosition(localSeat);
foreach (var card in player.Soul)
{
if (card != null && card.InstanceId != 0)
map[card.InstanceId] = new SourcePose(
position,
baseYaw,
cardScale);
}
}
if (player.Drop != null)
{
var position = _layout.DropPosition(localSeat);
foreach (var card in player.Drop)
{
if (card != null && card.InstanceId != 0)
map[card.InstanceId] = new SourcePose(
position,
baseYaw,
cardScale);
}
}
if (player.FaceUpDamage != null)
{
for (var i = 0; i < player.FaceUpDamage.Count; i++)
{
var card = player.FaceUpDamage[i];
if (card == null || card.InstanceId == 0)
continue;
var position = _layout.DamagePosition(localSeat, i);
var rotation = Quaternion.Euler(
0f,
_layout.BaseYaw(localSeat) - 90f,
0f);
map[card.InstanceId] = new SourcePose(
position, position,
rotation, rotation,
Vector3.one * Mathf.Max( cardScale);
0.05f, }
spec.Scale * _config.cardScale)); }
}
return map;
}
private static bool CanReuseByKey(Card3D existing, CardSpec spec)
{
// Анонимные карты (InstanceId == 0) привязаны к ключу зоны/индекса.
// Если и старая, и новая карты реальны и разные — это замена,
// а не переворот/сдвиг, и нужна полноценная анимация из источника.
if (existing.Ref.InstanceId == 0 || spec.Ref.InstanceId == 0)
return true;
return existing.Ref.InstanceId == spec.Ref.InstanceId;
} }
private BoardArt ResolveArt(string printingId) private BoardArt ResolveArt(string printingId)
@ -1780,6 +1904,20 @@ namespace Vanguard.Board3D
public Vector3 FromScale; public Vector3 FromScale;
} }
private readonly struct SourcePose
{
public readonly Vector3 Position;
public readonly Quaternion Rotation;
public readonly Vector3 Scale;
public SourcePose(Vector3 position, Quaternion rotation, Vector3 scale)
{
Position = position;
Rotation = rotation;
Scale = scale;
}
}
private sealed class CardSpec private sealed class CardSpec
{ {
public string Key; public string Key;

View File

@ -656,7 +656,7 @@ namespace Vanguard.UI
_catalog[item.PrintingId] = item; _catalog[item.PrintingId] = item;
if (_root.gameObject.activeSelf && _view != null) if (_root.gameObject.activeSelf && _view != null)
Render(); Render(animateMoves: false);
} }
catch (Exception exception) catch (Exception exception)
{ {
@ -1200,7 +1200,7 @@ namespace Vanguard.UI
"Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"), "Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"),
new Color(1f, 0.55f, 0.45f)); new Color(1f, 0.55f, 0.45f));
if (_view != null) if (_view != null)
Render(); Render(animateMoves: false);
} }
else else
{ {
@ -1707,14 +1707,14 @@ namespace Vanguard.UI
_onExit(); _onExit();
} }
private void Render() private void Render(bool animateMoves = true)
{ {
if (_view == null) if (_view == null)
return; return;
_cardMenu?.Hide(); _cardMenu?.Hide();
_board3D?.SetSelfPlayer(_view.You); _board3D?.SetSelfPlayer(_view.You);
_board3D?.Render(_view, BuildBoardRenderState()); _board3D?.Render(_view, BuildBoardRenderState(), animateMoves);
UpdateCombatStats(); UpdateCombatStats();
UpdateZoneStrips(); UpdateZoneStrips();
@ -2111,7 +2111,7 @@ namespace Vanguard.UI
_guardDiscardPrintingId = null; _guardDiscardPrintingId = null;
RefreshGuardSelection(); RefreshGuardSelection();
}); });
Render(); Render(animateMoves: false);
} }
private void AddGuardSelection(int instanceId, GuardIntent intent) private void AddGuardSelection(int instanceId, GuardIntent intent)
@ -2132,7 +2132,7 @@ namespace Vanguard.UI
? "Защита: выберите карты в руке или на поле." ? "Защита: выберите карты в руке или на поле."
: "Защита: выбрано " + count + " карт.", : "Защита: выбрано " + count + " карт.",
new Color(0.72f, 0.86f, 1f)); new Color(0.72f, 0.86f, 1f));
Render(); Render(animateMoves: false);
} }
private void ClearGuardSelection() private void ClearGuardSelection()
@ -2211,7 +2211,7 @@ namespace Vanguard.UI
} }
ClearGuardSelection(); ClearGuardSelection();
Render(); Render(animateMoves: false);
_sending = true; _sending = true;
SetStatus("Отправляем защиту…", Muted); SetStatus("Отправляем защиту…", Muted);
try try
@ -2255,7 +2255,7 @@ namespace Vanguard.UI
{ {
_sending = false; _sending = false;
if (_view != null) if (_view != null)
Render(); Render(animateMoves: false);
} }
} }
@ -2346,7 +2346,7 @@ namespace Vanguard.UI
"Выберите карту в руке для сброса.", "Выберите карту в руке для сброса.",
variants, variants,
null); null);
Render(); Render(animateMoves: false);
return; return;
} }
@ -2370,7 +2370,7 @@ namespace Vanguard.UI
SetStatus( SetStatus(
"Оплата способности: выберите карты для цены.", "Оплата способности: выберите карты для цены.",
new Color(0.98f, 0.82f, 0.42f)); new Color(0.98f, 0.82f, 0.42f));
Render(); Render(animateMoves: false);
} }
private void ConfirmAbilityCost() private void ConfirmAbilityCost()
@ -2846,7 +2846,7 @@ namespace Vanguard.UI
_abilityCostDiscardInstanceId = discardInstanceId; _abilityCostDiscardInstanceId = discardInstanceId;
_abilityDamageIndex = -1; _abilityDamageIndex = -1;
_abilityDamageIndexes.Clear(); _abilityDamageIndexes.Clear();
Render(); Render(animateMoves: false);
return; return;
} }
@ -3036,7 +3036,7 @@ namespace Vanguard.UI
.ToList(); .ToList();
if (guards.Count > 1) if (guards.Count > 1)
{ {
ShowGuardDiscardChoice(guards, Render); ShowGuardDiscardChoice(guards, () => Render(animateMoves: false));
return; return;
} }
@ -3151,7 +3151,7 @@ namespace Vanguard.UI
{ {
ClearAbilityCostSelection(); ClearAbilityCostSelection();
SetStatus("Оплата способности отменена.", Muted); SetStatus("Оплата способности отменена.", Muted);
Render(); Render(animateMoves: false);
return; return;
} }
@ -3161,7 +3161,7 @@ namespace Vanguard.UI
{ {
ClearGuardSelection(); ClearGuardSelection();
SetStatus("Выбор защиты снят.", Muted); SetStatus("Выбор защиты снят.", Muted);
Render(); Render(animateMoves: false);
return; return;
} }
@ -3659,7 +3659,7 @@ namespace Vanguard.UI
SetStatus( SetStatus(
"Выбрано карт урона: " + _abilityDamageIndexes.Count + " / " + CounterBlastAmount(_pendingAbilityCost) + ".", "Выбрано карт урона: " + _abilityDamageIndexes.Count + " / " + CounterBlastAmount(_pendingAbilityCost) + ".",
new Color(0.45f, 0.95f, 0.62f)); new Color(0.45f, 0.95f, 0.62f));
Render(); Render(animateMoves: false);
} }
private string CardName(string printingId) => private string CardName(string printingId) =>
@ -3706,7 +3706,7 @@ namespace Vanguard.UI
? "Кликните карты, которые вернутся в колоду." ? "Кликните карты, которые вернутся в колоду."
: "К обмену выбрано карт: " + _mulliganSelected.Count + ".", : "К обмену выбрано карт: " + _mulliganSelected.Count + ".",
new Color(0.98f, 0.82f, 0.42f)); new Color(0.98f, 0.82f, 0.42f));
Render(); Render(animateMoves: false);
} }
private void SendMulligan() private void SendMulligan()
@ -4166,7 +4166,7 @@ namespace Vanguard.UI
_intents, "CancelTarget", "Отмена", () => _intents, "CancelTarget", "Отмена", () =>
{ {
ClearSelection(); ClearSelection();
Render(); Render(animateMoves: false);
}, 17); }, 17);
UiKit.SetPreferredHeight(cancel, 44f); UiKit.SetPreferredHeight(cancel, 44f);
return; return;

View File

@ -729,7 +729,7 @@ namespace Vanguard.Tests
.SetValue(battle, match); .SetValue(battle, match);
typeof(BattleView) typeof(BattleView)
.GetMethod("Render", BindingFlags.Instance | BindingFlags.NonPublic)! .GetMethod("Render", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(battle, null); .Invoke(battle, new object[] { true });
Assert.IsTrue(Exists("GameBoard3D")); Assert.IsTrue(Exists("GameBoard3D"));

View File

@ -441,6 +441,185 @@ namespace Vanguard.Tests
} }
} }
[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 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() => private static List<Card3D> ActiveCards() =>
Resources.FindObjectsOfTypeAll<Card3D>() Resources.FindObjectsOfTypeAll<Card3D>()
.Where(card => card.gameObject.activeInHierarchy) .Where(card => card.gameObject.activeInHierarchy)

View File

@ -125,11 +125,11 @@
### TASK-2026-001 — Плавность карточных перемещений и корректные источники ### TASK-2026-001 — Плавность карточных перемещений и корректные источники
- **Статус:** `[ ]` - **Статус:** `[!]`
- **Создано:** 2026-10-24 - **Создано:** 2026-10-24
- **Анализ:** DeepSeek выполнен - **Анализ:** DeepSeek выполнен
- **Реализация:** Kimi, не начата - **Реализация:** Kimi выполнена
- **Проверка:** не выполнена - **Проверка:** автотесты 46/46; ручная C10 — пользователь
- **Затронутые системы:** `GameBoard3D`, `Card3D`, `CardMoveAnimator`, - **Затронутые системы:** `GameBoard3D`, `Card3D`, `CardMoveAnimator`,
`BattleView`, `AnimationConfig`, EditMode-тесты. `BattleView`, `AnimationConfig`, EditMode-тесты.
@ -233,145 +233,136 @@
#### C. Чек-лист изменений #### C. Чек-лист изменений
- [ ] **C1. `MODIFY` `GameBoard3D.Render`** — стабильная идентичность - [x] **C1. `MODIFY` `GameBoard3D.Render`** — стабильная идентичность
анонимных карт. анонимных карт.
- Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`. - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`.
- В `CardAssignment` добавить признак `ReuseByKey`/`StateOnly` (имя выбрать - Добавлена ветка reuse-by-key до поиска по `InstanceId`.
по стилю файла). - Переиспользуется `Card3D` по `spec.Key`, если старая/новая карта анонимна
- Если `_cards` уже содержит `spec.Key` — переиспользовать эту `Card3D` или совпадают реальные `InstanceId`; иначе используется `previousByInstance`
даже при `InstanceId == 0`; не вызывать `RentCard`. или `RentCard`.
- Для `InstanceId == 0` не ставить `moved = true` только из-за отсутствия - При смене `0→real` в том же слоте карта сохраняется и обновляется
записи в `previousByInstance`. `Bind`/`SetArt`/`SetPose` без анимации.
- При смене `InstanceId` 0→real в том же слоте (открытие урона) сохранить - Карты с исчезнувшими ключами освобождаются через `ReleaseCard`.
карту, обновить art/bind/face state; не отправлять её в полёт из колоды. - Реализация: выполнена.
- При исчезновении ключа освобождать карту как раньше. - Проверка: `Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves`,
- Реализация: не выполнена. `Revealing_Face_Down_Damage_Reuses_Card_And_Stays_In_Damage_Zone` — проходят.
- Проверка: не выполнена.
- Зависит от: C2, C4. - Зависит от: C2, C4.
- [ ] **C2. `MODIFY` `GameBoard3D.AddOpponentHandSpecs` / - [x] **C2. `MODIFY` `GameBoard3D.AddOpponentHandSpecs` /
`AddDamageSpecs`** — стабильные ключи и явная политика анимации. `AddDamageSpecs`** — стабильные ключи и явная политика анимации.
- Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`. - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`.
- Оставить `OH:player:i` для скрытой руки и `D:player:i` для слота урона; - Ключи `OH:player:i` и `D:player:i` оставлены; `InstanceId=0` не считается
не использовать `InstanceId=0` как личность. личностью.
- Для скрытых карт при стабильном ключе не анимировать; при добавлении - Скрытые карты при стабильном ключе не анимируются; новая скрытая карта в
новой скрытой карты в конец — разрешить один переход из Deck. конце анимируется из Deck.
- Для урона: смена 0↔real или real→real в том же слоте — обновление - Для урона: смена `0↔real` или `real→real` в том же слоте обрабатывается
карты/переворот, не новый полёт из Deck. reuse-by-key, а не полётом из Deck.
- Реализация: не выполнена. - Реализация: выполнена.
- Проверка: не выполнена. - Проверка: см. C1 + `Board_Renders_Field_Hand_Guardian_Damage_And_Soul`.
- Зависит от: C1. - Зависит от: C1.
- [ ] **C3. `MODIFY` `GameBoard3D` — карта источника из предыдущего - [x] **C3. `MODIFY` `GameBoard3D` — карта источника из предыдущего
`MatchView`.** `MatchView`.**
- Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`. - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`.
- Сохранять предыдущий `MatchView`/его зоны до присваивания нового - Сохраняются `_previousView` и `_sourceByInstance` до замены `_view`.
`_view`. - Построен `Dictionary<int, CardSourcePose>` для `Soul`, `Drop`,
- Построить `Dictionary<int, SourcePose>` для `Hand`, `Guardian`, `Soul`, `FaceUpDamage`; источники `Hand`/`Guardian`/`Trigger`/`Field` покрываются
`Drop`, `Trigger`, `FaceUpDamage` (и при необходимости field), где существующими `Card3D` через `previousByInstance`.
`SourcePose` содержит position/rotation/scale/faceUp. - `ResolveSourcePose(spec)`: приоритет — `Card3D` (transfer), затем зона из
- `SourcePose(spec)`: приоритет — текущая `Card3D` (для transfer), затем `_sourceByInstance`, затем `DeckPosition` с рубашкой вверх.
previous-view зона по `InstanceId`, затем `DeckPosition`.
- Источники: - Источники:
- Deck: `DeckPosition`, рубашка вверх (rotation с X-flip); - Deck: `DeckPosition`, рубашка вверх (`baseYaw * X-flip 180°`);
- Soul: `SoulPosition`, лицом вверх; - Soul: `SoulPosition`, лицом вверх;
- Drop: `DropPosition`, лицом вверх; - Drop: `DropPosition`, лицом вверх;
- Guardian: фактическая `Card3D` или `GuardianPosition`, +90°;
- Trigger: фактическая `Card3D` или `TriggerPosition`, ±90°;
- FaceUpDamage: `DamagePosition`, лицом вверх и на боку; - FaceUpDamage: `DamagePosition`, лицом вверх и на боку;
- Hand: карта в руке, лицом вверх; - Hand/Guardian/Trigger/Field — через `previousByInstance`/transfer.
- Field: фактическая `Card3D`/`CirclePosition`. - Fallback Deck используется только с комментарием и покрывается тестами.
- Не придумывать источник, если `InstanceId` нет в previous view и он не - `PlaceImmediate` получает rotation с учётом flip.
Deck: fallback Deck допустим только с комментарием/тестом. - Реализация: выполнена.
- `PlaceImmediate` для нового источника должен получать rotation с - Проверка: `New_Field_Unit_Starts_From_Hand_Not_Deck` — проходит.
правильным flip, а не только yaw.
- Реализация: не выполнена.
- Проверка: не выполнена.
- Зависит от: C1, C2. - Зависит от: C1, C2.
- [ ] **C4. `MODIFY` `CardMoveAnimator.Enqueue`** — один активный/queued - [x] **C4. `MODIFY` `CardMoveAnimator.Enqueue`** — один активный/queued
request на карту. request на карту.
- Файл: `client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs`. - Файл: `client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs`.
- Перед постановкой нового request отменять/удалять старый request этой же - В начале `Enqueue` вызывается `CancelCard(card)`, что удаляет queued/
`Card3D` (queued и active), не трогая другие карты. active request этой же карты, не трогая другие.
- Новый request должен начинаться с текущей визуальной позиции карты, а не - Источник (`from*`) передаётся из `GameBoard3D` как текущая визуальная
со старого `FromPosition`; если target совпадает — не создавать запрос. позиция карты; target проверяется в `Card3D.StartArcMove`.
- Не допускать дублей в `_active`; сохранить timeout/`CancelCard`/ - `CancelCard`, `CancelAll`, timeout и снятие `card-move`-лока сохранены.
`CancelAll` и снятие `card-move`-лока. - Реализация: выполнена.
- Реализация: не выполнена. - Проверка: `CardMoveAnimator_Coalesces_Requests_For_Same_Card`,
- Проверка: не выполнена. `CardMoveAnimator_Releases_Input_Lock_When_Card_Is_Cancelled` — проходят.
- Зависит от: C1. - Зависит от: C1.
- [ ] **C5. `MODIFY` `GameBoard3D.Render` + `BattleView.Render`** — явно - [x] **C5. `MODIFY` `GameBoard3D.Render` + `BattleView.Render`** — явно
различать snapshot-рендер и локальный UI-рендер. различать snapshot-рендер и локальный UI-рендер.
- Файлы: `GameBoard3D.cs`, `client/Assets/Vanguard/Runtime/UI/BattleView.cs`. - Файлы: `GameBoard3D.cs`, `client/Assets/Vanguard/Runtime/UI/BattleView.cs`.
- Добавить параметр `animateMoves` (или эквивалентный options-объект) без - `GameBoard3D.Render` получил параметр `bool animateMoves = true` без
публичного изменения серверных DTO. изменения DTO.
- `OnSnapshot` → `animateMoves: true`. - `BattleView.Render(bool animateMoves = true)`; `OnSnapshot` вызывает
- Локальные `Render()` (guard selection, card menu, status, cost counter) → `Render()` (`true`); все локальные вызовы заменены на
`animateMoves: false`: обновлять highlight/UI, но не запускать новые `Render(animateMoves: false)`.
move-анимации и не менять идентичность карт. - При `animateMoves: false` move-анимации не запускаются; идентичность карт
- Если при локальном Render карта реально новая (не должно происходить), не меняется. Появление реально новой карты логирует warning.
поставить в target pose без анимации и записать warning в debug-лог. - Реализация: выполнена.
- Реализация: не выполнена. - Проверка: `Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves`,
- Проверка: не выполнена. `ClientSmokeTests.Battle_Renders_3D_Damage_And_Rested_Cards` — проходят.
- Зависит от: C1, C2. - Зависит от: C1, C2.
- [ ] **C6. `MODIFY` `Card3D` / `GameBoard3D.UpdateHandView`** — не - [x] **C6. `MODIFY` `GameBoard3D.UpdateHandView`** — не перетирать движение
перетирать движение руки. руки.
- Файлы: `client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs`, - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`.
`GameBoard3D.cs`. - В `UpdateHandView` добавлено условие: пропускать карту, если
- Проверить сценарий «карта летит из Deck в Hand»: `UpdateHandView` `card.IsMoving`, чтобы `SetPose` не менял target во время arc-перехода.
вызывает `SetPose` каждый кадр; target должен совпадать с target request. - `GetHandViewPose` и привязка руки к камере не изменены.
- Если подтверждено расхождение — обновлять target request при изменении - Реализация: выполнена.
hand pose или не вызывать `SetPose` для карты, которая ещё в - Проверка: `Hand_Is_Anchored_To_Camera_Bottom_Center_And_Faces_Player`,
`CardMoveAnimator.IsBusy` для этого instance. `New_Field_Unit_Starts_From_Hand_Not_Deck` — проходят.
- Не отключать руку от камеры и не менять `GetHandViewPose` без
необходимости.
- Реализация: не выполнена.
- Проверка: не выполнена.
- Зависит от: C3, C4. - Зависит от: C3, C4.
- [ ] **C7. `MODIFY` `AnimationConfig.asset` + проверка значений.** - [x] **C7. `MODIFY` `AnimationConfig.asset` + проверка значений.**
- Файл: `client/Assets/Vanguard/Resources/AnimationConfig.asset`. - Файл: `client/Assets/Vanguard/Resources/AnimationConfig.asset`.
- Добавить/сериализовать `moveDuration`, `importantMoveDuration`, - Добавлены/сериализованы все поля из `AnimationConfig.cs`:
`moveDelayBetweenCards`, `arcHeight`, camera/grow/dash-поля из `moveDuration: 0.5`, `importantMoveDuration: 0.9`,
`AnimationConfig.cs`. `moveDelayBetweenCards: 0.18`, `arcHeight: 0.45`,
- Зафиксировать разумные значения: движение ~0.4–0.6, важное ~0.8–1.0, `cameraZoomDuration: 0.35`, `triggerRevealHold: 1.2`,
stagger ~0.15–0.25, arc ~0.3–0.5; не менять длительности feedback без `cameraReturnDuration: 0.4`, `cameraRezoomDuration: 0.3`,
запроса. `growDuration: 0.35`, `dashScrollSpeed: 1.5`,
- Проверить в Unity Inspector, что значения не нулевые и совпадают с кодом. `boostGrowDuration: 0.3`, `boostThickness: 0.8`,
- Реализация: не выполнена. `arrowFadeDuration: 0.2`.
- Проверка: не выполнена. - Длительности feedback не менялись.
- Реализация: выполнена.
- Проверка: `CardOverlayTests.Animation_Config_Asset_Is_Loaded_From_Resources`,
поле `arcHeight` читается в `GameBoard3D.Render` — проходят.
- Зависит от: C5 (можно параллельно). - Зависит от: C5 (можно параллельно).
- [ ] **C8. `TEST` `GameBoard3DTests`** — регрессии анимаций. - [x] **C8. `TEST` `GameBoard3DTests`** — регрессии анимаций.
- Файл: `client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs`. - Файл: `client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs`.
- Добавить тесты: - Добавлены тесты:
1. повторный `Render` с тем же view/state не увеличивает число 1. `Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves`;
`Card3D` для opponent hand/face-down damage; 2. `Revealing_Face_Down_Damage_Reuses_Card_And_Stays_In_Damage_Zone`;
2. повторный `Render` не создаёт новый `CardMoveAnimator` request для 3. `New_Field_Unit_Starts_From_Hand_Not_Deck`;
стабильного ключа; 4. `CardMoveAnimator_Coalesces_Requests_For_Same_Card`.
3. открытие face-down damage переиспользует `Card3D` и не отправляет его - В `CardMoveAnimator` добавлены read-only seam-свойства:
из Deck; `PendingCount`, `ActiveCount`, `IsCardQueued`, `IsCardActive`.
4. новый unit из hand сохраняет source-позицию руки, а не Deck; - Исправлен `ClientSmokeTests.Battle_Renders_3D_Damage_And_Rested_Cards`
5. `CardMoveAnimator` coalesce: второй Enqueue той же карты не оставляет для вызова `Render` с новой сигнатурой через reflection.
дубль в active/queue; - Реализация: выполнена.
6. `CancelCard` по-прежнему освобождает `card-move`-лок. - Проверка: все новые тесты + старые — 46/46.
- Для проверки source/queue при необходимости добавить **тестовый seam**
(read-only snapshot последних запросов/позиций) в `CardMoveAnimator` или
`GameBoard3D`, не меняя публичное поведение.
- Реализация: не выполнена.
- Проверка: не выполнена.
- Зависит от: C1–C6. - Зависит от: C1–C6.
- [ ] **C9. `VERIFY` smoke / compile / EditMode.** - [x] **C9. `VERIFY` smoke / compile / EditMode.**
- Запустить EditMode в копии проекта без `Library`. - Скопирован проект без `Library`;
- `PLAYMAT_SMOKE`; `SCENE_SMOKE` — если затронуты сцены/сборка. `Unity 6000.6.6f1 -batchmode -nographics -runTests -testPlatform EditMode`.
- Проверить отсутствие ошибок компиляции и предупреждений о missing - EditMode: **46/46 passed** (включая 15 `GameBoard3DTests`, 12
serialized fields. `ClientSmokeTests`, 6 `CardOverlayTests`, 12 `PlaymatLayoutTests`, 1
- Реализация: не выполнена. Addressables stub).
- Проверка: не выполнена. - `PLAYMAT_SMOKE success=True board=True cards=25 hand=5/5 fieldVisible=True`.
- `SCENE_SMOKE success=True active=Login env=True login=True`.
- Ошибок компиляции нет; warning CS8632 в `ClientSmokeTests` — pre-existing.
- Реализация: выполнена.
- Проверка: выполнена.
- Зависит от: C1–C8. - Зависит от: C1–C8.
- [ ] **C10. `VERIFY` ручная проверка пользователем.** - [ ] **C10. `VERIFY` ручная проверка пользователем.**
@ -380,8 +371,8 @@
повторные клики по UI во время/после анимации. повторные клики по UI во время/после анимации.
- Ожидаемо: нет полёта скрытых карт из колоды при каждом клике; источник - Ожидаемо: нет полёта скрытых карт из колоды при каждом клике; источник
совпадает с зоной; нет рывков/двойных стартов; ввод не зависает. совпадает с зоной; нет рывков/двойных стартов; ввод не зависает.
- Реализация: не выполнена. - Реализация: не применима.
- Проверка: не выполнена — только пользователь. - Проверка: остаётся на пользователе.
- Зависит от: C9. - Зависит от: C9.
#### D. Последовательность выполнения #### D. Последовательность выполнения
@ -444,9 +435,12 @@
#### G. Результат #### G. Результат
- **Реализовано:** не начато. - **Реализовано:** C1–C8 (см. чек-лист).
- **Проверки:** не выполнены. - **Проверки:** EditMode 46/46 в копии без `Library`; компиляция без ошибок.
- **Не завершено/заблокировано:** ожидает этапа Kimi; критических вопросов - **Не завершено/заблокировано:** C10 — ручная проверка пользователем.
пользователю на данный момент нет. - **Skill обновлён:** не требуется; изменения технические, не затрагивают
- **Skill обновлён:** не требуется до подтверждения новых постоянных правил. подтверждённые правила/архитектуру.
- **Git:** изменён только план/документация, код не менялся. - **Git:** будут изменены `GameBoard3D.cs`, `CardMoveAnimator.cs`,
`BattleView.cs`, `AnimationConfig.asset`, `GameBoard3DTests.cs`,
`ClientSmokeTests.cs`, `IMPLEMENTATION_PLAN.md`, `docs/SESSION-STATE.md`,
`docs/HANDOFF.md`.

View File

@ -6,6 +6,36 @@
--- ---
## 2026-10-24 — TASK-2026-001: плавность карточных перемещений
- Реализована стабильная идентичность анонимных карт (`InstanceId == 0`) в
`GameBoard3D.Render`: скрытая рука противника и закрытый урон не
пересоздаются на каждом render, а переиспользуются по стабильному ключу
зоны/индекса.
- Добавлен source map из предыдущего `MatchView` для зон без собственных
`Card3D` (`Soul`, `Drop`, `FaceUpDamage`); fallback — колода с рубашкой
вверх. Новые юниты из руки/поля/триггера/урона берут источник из фактической
старой позиции.
- `CardMoveAnimator.Enqueue` теперь отменяет queued/active запрос той же карты
перед добавлением нового, исключая дубли и сброс в середину пути.
- `BattleView.Render` разделён на snapshot-рендер (`animateMoves: true`) и
локальный UI-рендер (`animateMoves: false`); все локальные вызовы обновлены.
- `GameBoard3D.UpdateHandView` больше не перетирает target движущейся карты.
- `AnimationConfig.asset` синхронизирован с кодом: добавлены длительности,
stagger, arcHeight, camera/grow/dash-поля.
- Добавлены EditMode-тесты: повторный render скрытых карт, открытие урона,
source из руки, coalescing аниматора; добавлены read-only seam-свойства
`PendingCount`/`ActiveCount`/`IsCardQueued`/`IsCardActive`.
- Исправлен `ClientSmokeTests.Battle_Renders_3D_Damage_And_Rested_Cards` для
вызова `Render` с новой сигнатурой через reflection.
- Проверки: EditMode в копии без `Library` — **46/46 passed**;
`PLAYMAT_SMOKE success=True`; `SCENE_SMOKE success=True`; компиляция
оригинального проекта без ошибок. Сервер/правила/Shared/контент не затронуты.
- Ручная проверка игровой механики (draw/call/ride/damage/etc.) остаётся за
пользователем (C10).
---
## 2026-10-23 — защита и оплата способностей ## 2026-10-23 — защита и оплата способностей
- Панель «Фазы» переименована в «Действия»; защита перенесена туда. - Панель «Фазы» переименована в «Действия»; защита перенесена туда.

View File

@ -273,14 +273,14 @@ Standalone Windows:
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
``` ```
Клиентские проверки: **42/42** EditMode (сборка всех экранов, импорт арта, Клиентские проверки: **46/46** EditMode (включая новые тесты TASK-2026-001:
перевод статических/динамических строк, live-переключение языка, рендер повторный render скрытых карт, открытие урона, source-позиция нового юнита,
3D-доски с уроном и отдыхающей картой, геометрия поля, правила coalescing `CardMoveAnimator`; сборка всех экранов, импорт арта, перевод
строк, live-переключение языка, рендер 3D-доски, геометрия поля, правила
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
CardListView/Detail, зоны и рука противника, InputLock, контекстное меню, CardListView/Detail, зоны и рука противника, InputLock, контекстное меню,
стрелка атаки, prefab-оверлеи, раскладка на 1920×1080, авто-завершение стрелка атаки, prefab-оверлеи, раскладка на 1920×1080, авто-завершение
муллигана без кнопки после обмена, одна кнопка защиты в панели действий, муллигана, одна кнопка защиты, отсутствие дублей в Guardian), `SCENE_SMOKE
отсутствие дублей карт в Guardian), `SCENE_SMOKE
success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`. success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
@ -356,15 +356,20 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
## Следующие шаги ## Следующие шаги
1. 3D UX: P0 (движение, ориентации, камера/триггер) и базовый P1 (стрелки, 1. **TASK-2026-001 — ручная проверка пользователем** (C10): draw, call, ride,
move/swap, guard, trigger, drive/damage, retire, search, открытие скрытого
урона, рука противника, повторные клики UI во время/после анимации.
Автотесты 46/46; код изменён в `GameBoard3D`, `CardMoveAnimator`,
`BattleView`, `AnimationConfig.asset` + тесты.
2. 3D UX: P0 (движение, ориентации, камера/триггер) и базовый P1 (стрелки,
multi-target Counter Blast) готовы. Осталось: `AttackResolutionQueue`, multi-target Counter Blast) готовы. Осталось: `AttackResolutionQueue`,
multi-target Soul Blast/прочие стоимости, отдельный `CostPaymentOverlay`, multi-target Soul Blast/прочие стоимости, отдельный `CostPaymentOverlay`,
world-space Power/Shield (верхний strip принят как HUD), world-space world-space Power/Shield (верхний strip принят как HUD), world-space
dash shader; полировка позиций на конкретном мониторе. dash shader; полировка позиций на конкретном мониторе.
2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты. 3. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
3. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost. 4. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost.
4. Звук и настройки, DOTween. 5. Звук и настройки, DOTween.
5. Лаунчер WPF. 6. Лаунчер WPF.
Тренировка с ботом и боевой UI с артами/анимациями/локализацией уже есть. Тренировка с ботом и боевой UI с артами/анимациями/локализацией уже есть.