From 29822be2adc4acb85cecd0fd0f202ea4f92baccc Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sat, 24 Oct 2026 20:45:51 +0500 Subject: [PATCH] client: TASK-2026-001 smooth card movement animation fixes - 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. --- .../Vanguard/Resources/AnimationConfig.asset | 13 + .../Board3D/Animation/CardMoveAnimator.cs | 35 +++ .../Runtime/Board3D/View/GameBoard3D.cs | 178 ++++++++++++-- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 34 +-- .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 2 +- .../Vanguard/Tests/Editor/GameBoard3DTests.cs | 179 ++++++++++++++ docs/Development/IMPLEMENTATION_PLAN.md | 230 +++++++++--------- docs/HANDOFF.md | 30 +++ docs/SESSION-STATE.md | 25 +- 9 files changed, 560 insertions(+), 166 deletions(-) diff --git a/client/Assets/Vanguard/Resources/AnimationConfig.asset b/client/Assets/Vanguard/Resources/AnimationConfig.asset index cf04947..ab72854 100644 --- a/client/Assets/Vanguard/Resources/AnimationConfig.asset +++ b/client/Assets/Vanguard/Resources/AnimationConfig.asset @@ -30,3 +30,16 @@ MonoBehaviour: inputLockTail: 0.15 arrowShowDuration: 0.25 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 diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs index 5764fa0..a1461b6 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs @@ -44,6 +44,37 @@ namespace Vanguard.Board3D 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) { _config = config; @@ -60,6 +91,10 @@ namespace Vanguard.Board3D if (card == null || card.IsDragging) return; + // У одной карты не может быть нескольких активных/ожидающих + // запросов одновременно: старый отменяем, новый стартует из + // текущей визуальной позиции. + CancelCard(card); EnsureLock(); _queue.Enqueue(new Request { diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index ad9129f..b5f8a5a 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -40,6 +40,8 @@ namespace Vanguard.Board3D private PlaymatLayout _layout; private float _handViewportX; private MatchView _view; + private MatchView _previousView; + private Dictionary _sourceByInstance; private int _selfPlayer; private bool _hasRendered; private bool _built; @@ -238,7 +240,7 @@ namespace Vanguard.Board3D gameObject.SetActive(visible); } - public void Render(MatchView view, BoardRenderState state) + public void Render(MatchView view, BoardRenderState state, bool animateMoves = true) { if (!_built) return; @@ -247,11 +249,18 @@ namespace Vanguard.Board3D { ClearCards(); _view = null; + _previousView = null; + _sourceByInstance = null; return; } SetSelfPlayer(view.You); + + // Запоминаем старые позиции карт по InstanceId, пока не заменили _view. + _sourceByInstance = BuildSourceMap(); + _previousView = _view; _view = view; + state ??= new BoardRenderState(); if (_camera == null) _camera = Camera.main; @@ -284,9 +293,6 @@ namespace Vanguard.Board3D var assignments = new Dictionary( StringComparer.Ordinal); - // Сначала распределяем старые Card3D по InstanceId. Нельзя - // освобождать карту из занятого ключа: она может участвовать - // в обмене двух кругов и должна переехать в другой ключ. foreach (var spec in specs) { var transferred = false; @@ -296,7 +302,27 @@ namespace Vanguard.Board3D var fromScale = default(Vector3); 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( spec.Ref.InstanceId, out var previous) @@ -351,12 +377,10 @@ namespace Vanguard.Board3D if (moved && !assignment.Transferred) { - var source = SourcePose(spec); + var source = ResolveSourcePose(spec); assignment.FromPosition = source.Position; assignment.FromRotation = source.Rotation; - assignment.FromScale = Vector3.one * Mathf.Max( - 0.05f, - spec.Scale * _config.cardScale); + assignment.FromScale = source.Scale; card.PlaceImmediate( assignment.FromPosition, assignment.FromRotation, @@ -374,7 +398,7 @@ namespace Vanguard.Board3D spec.DamageLayout, spec.Scale * Mathf.Max(0.05f, _config.cardScale)); - if (moved) + if (moved && animateMoves) { var important = spec.Ref.Zone is BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop; @@ -396,6 +420,13 @@ namespace Vanguard.Board3D duration, arc); } + else if (moved && !animateMoves) + { + Debug.LogWarning( + "[GameBoard3D] Local render moved card " + + spec.Ref + + " without animation."); + } card.SetHighlight(spec.Highlight); card.SetStats(spec.Stats, spec.StatsModified); @@ -431,6 +462,8 @@ namespace Vanguard.Board3D ClearCards(); _handBindings.Clear(); _view = null; + _previousView = null; + _sourceByInstance = null; _hasRendered = false; _arrowDirector?.HideImmediate(); ReleaseBoardLock(); @@ -1111,7 +1144,9 @@ namespace Vanguard.Board3D foreach (var binding in _handBindings) { - if (binding.Card == null || binding.Card.IsDragging) + if (binding.Card == null + || binding.Card.IsDragging + || binding.Card.IsMoving) continue; GetHandViewPose( @@ -1652,23 +1687,112 @@ namespace Vanguard.Board3D ApplySlotHighlights(); } - private (Vector3 Position, Quaternion Rotation, Vector3 Scale) SourcePose( + private (Vector3 Position, Quaternion Rotation, Vector3 Scale) ResolveSourcePose( CardSpec spec) { var localSeat = _layout.ToLocalSeat( spec.Ref.Player, _selfPlayer); - var position = _layout.DeckPosition(localSeat); - var rotation = Quaternion.Euler( + var baseYaw = Quaternion.Euler( 0f, _layout.BaseYaw(localSeat), 0f); - return ( - position, - rotation, - Vector3.one * Mathf.Max( - 0.05f, - spec.Scale * _config.cardScale)); + 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 BuildSourceMap() + { + var map = new Dictionary(); + if (_view == null) + return map; + + foreach (var player in _view.Players) + { + if (player == null) + continue; + + var localSeat = _layout.ToLocalSeat(player.Index, _selfPlayer); + var baseYaw = Quaternion.Euler( + 0f, + _layout.BaseYaw(localSeat), + 0f); + var cardScale = Vector3.one * _config.cardScale; + + if (player.Soul != null) + { + var position = _layout.SoulPosition(localSeat); + foreach (var card in player.Soul) + { + if (card != null && card.InstanceId != 0) + map[card.InstanceId] = new SourcePose( + position, + baseYaw, + cardScale); + } + } + + if (player.Drop != null) + { + var position = _layout.DropPosition(localSeat); + foreach (var card in player.Drop) + { + if (card != null && card.InstanceId != 0) + map[card.InstanceId] = new SourcePose( + position, + baseYaw, + cardScale); + } + } + + if (player.FaceUpDamage != null) + { + for (var i = 0; i < player.FaceUpDamage.Count; i++) + { + var card = player.FaceUpDamage[i]; + if (card == null || card.InstanceId == 0) + continue; + + var position = _layout.DamagePosition(localSeat, i); + var rotation = Quaternion.Euler( + 0f, + _layout.BaseYaw(localSeat) - 90f, + 0f); + map[card.InstanceId] = new SourcePose( + position, + rotation, + cardScale); + } + } + } + + return map; + } + + private static bool CanReuseByKey(Card3D existing, CardSpec spec) + { + // Анонимные карты (InstanceId == 0) привязаны к ключу зоны/индекса. + // Если и старая, и новая карты реальны и разные — это замена, + // а не переворот/сдвиг, и нужна полноценная анимация из источника. + if (existing.Ref.InstanceId == 0 || spec.Ref.InstanceId == 0) + return true; + + return existing.Ref.InstanceId == spec.Ref.InstanceId; } private BoardArt ResolveArt(string printingId) @@ -1780,6 +1904,20 @@ namespace Vanguard.Board3D public Vector3 FromScale; } + private readonly struct SourcePose + { + public readonly Vector3 Position; + public readonly Quaternion Rotation; + public readonly Vector3 Scale; + + public SourcePose(Vector3 position, Quaternion rotation, Vector3 scale) + { + Position = position; + Rotation = rotation; + Scale = scale; + } + } + private sealed class CardSpec { public string Key; diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 136f0c5..871b9e5 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -656,7 +656,7 @@ namespace Vanguard.UI _catalog[item.PrintingId] = item; if (_root.gameObject.activeSelf && _view != null) - Render(); + Render(animateMoves: false); } catch (Exception exception) { @@ -1200,7 +1200,7 @@ namespace Vanguard.UI "Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"), new Color(1f, 0.55f, 0.45f)); if (_view != null) - Render(); + Render(animateMoves: false); } else { @@ -1707,14 +1707,14 @@ namespace Vanguard.UI _onExit(); } - private void Render() + private void Render(bool animateMoves = true) { if (_view == null) return; _cardMenu?.Hide(); _board3D?.SetSelfPlayer(_view.You); - _board3D?.Render(_view, BuildBoardRenderState()); + _board3D?.Render(_view, BuildBoardRenderState(), animateMoves); UpdateCombatStats(); UpdateZoneStrips(); @@ -2111,7 +2111,7 @@ namespace Vanguard.UI _guardDiscardPrintingId = null; RefreshGuardSelection(); }); - Render(); + Render(animateMoves: false); } private void AddGuardSelection(int instanceId, GuardIntent intent) @@ -2132,7 +2132,7 @@ namespace Vanguard.UI ? "Защита: выберите карты в руке или на поле." : "Защита: выбрано " + count + " карт.", new Color(0.72f, 0.86f, 1f)); - Render(); + Render(animateMoves: false); } private void ClearGuardSelection() @@ -2211,7 +2211,7 @@ namespace Vanguard.UI } ClearGuardSelection(); - Render(); + Render(animateMoves: false); _sending = true; SetStatus("Отправляем защиту…", Muted); try @@ -2255,7 +2255,7 @@ namespace Vanguard.UI { _sending = false; if (_view != null) - Render(); + Render(animateMoves: false); } } @@ -2346,7 +2346,7 @@ namespace Vanguard.UI "Выберите карту в руке для сброса.", variants, null); - Render(); + Render(animateMoves: false); return; } @@ -2370,7 +2370,7 @@ namespace Vanguard.UI SetStatus( "Оплата способности: выберите карты для цены.", new Color(0.98f, 0.82f, 0.42f)); - Render(); + Render(animateMoves: false); } private void ConfirmAbilityCost() @@ -2846,7 +2846,7 @@ namespace Vanguard.UI _abilityCostDiscardInstanceId = discardInstanceId; _abilityDamageIndex = -1; _abilityDamageIndexes.Clear(); - Render(); + Render(animateMoves: false); return; } @@ -3036,7 +3036,7 @@ namespace Vanguard.UI .ToList(); if (guards.Count > 1) { - ShowGuardDiscardChoice(guards, Render); + ShowGuardDiscardChoice(guards, () => Render(animateMoves: false)); return; } @@ -3151,7 +3151,7 @@ namespace Vanguard.UI { ClearAbilityCostSelection(); SetStatus("Оплата способности отменена.", Muted); - Render(); + Render(animateMoves: false); return; } @@ -3161,7 +3161,7 @@ namespace Vanguard.UI { ClearGuardSelection(); SetStatus("Выбор защиты снят.", Muted); - Render(); + Render(animateMoves: false); return; } @@ -3659,7 +3659,7 @@ namespace Vanguard.UI SetStatus( "Выбрано карт урона: " + _abilityDamageIndexes.Count + " / " + CounterBlastAmount(_pendingAbilityCost) + ".", new Color(0.45f, 0.95f, 0.62f)); - Render(); + Render(animateMoves: false); } private string CardName(string printingId) => @@ -3706,7 +3706,7 @@ namespace Vanguard.UI ? "Кликните карты, которые вернутся в колоду." : "К обмену выбрано карт: " + _mulliganSelected.Count + ".", new Color(0.98f, 0.82f, 0.42f)); - Render(); + Render(animateMoves: false); } private void SendMulligan() @@ -4166,7 +4166,7 @@ namespace Vanguard.UI _intents, "CancelTarget", "Отмена", () => { ClearSelection(); - Render(); + Render(animateMoves: false); }, 17); UiKit.SetPreferredHeight(cancel, 44f); return; diff --git a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs index 1b72a23..732c8e2 100644 --- a/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/ClientSmokeTests.cs @@ -729,7 +729,7 @@ namespace Vanguard.Tests .SetValue(battle, match); typeof(BattleView) .GetMethod("Render", BindingFlags.Instance | BindingFlags.NonPublic)! - .Invoke(battle, null); + .Invoke(battle, new object[] { true }); Assert.IsTrue(Exists("GameBoard3D")); diff --git a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs index ccbaab4..f235588 100644 --- a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs @@ -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 ActiveCards() => Resources.FindObjectsOfTypeAll() .Where(card => card.gameObject.activeInHierarchy) diff --git a/docs/Development/IMPLEMENTATION_PLAN.md b/docs/Development/IMPLEMENTATION_PLAN.md index aaa4666..036c382 100644 --- a/docs/Development/IMPLEMENTATION_PLAN.md +++ b/docs/Development/IMPLEMENTATION_PLAN.md @@ -125,11 +125,11 @@ ### TASK-2026-001 — Плавность карточных перемещений и корректные источники -- **Статус:** `[ ]` +- **Статус:** `[!]` - **Создано:** 2026-10-24 - **Анализ:** DeepSeek выполнен -- **Реализация:** Kimi, не начата -- **Проверка:** не выполнена +- **Реализация:** Kimi выполнена +- **Проверка:** автотесты 46/46; ручная C10 — пользователь - **Затронутые системы:** `GameBoard3D`, `Card3D`, `CardMoveAnimator`, `BattleView`, `AnimationConfig`, EditMode-тесты. @@ -233,145 +233,136 @@ #### C. Чек-лист изменений -- [ ] **C1. `MODIFY` `GameBoard3D.Render`** — стабильная идентичность +- [x] **C1. `MODIFY` `GameBoard3D.Render`** — стабильная идентичность анонимных карт. - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`. - - В `CardAssignment` добавить признак `ReuseByKey`/`StateOnly` (имя выбрать - по стилю файла). - - Если `_cards` уже содержит `spec.Key` — переиспользовать эту `Card3D` - даже при `InstanceId == 0`; не вызывать `RentCard`. - - Для `InstanceId == 0` не ставить `moved = true` только из-за отсутствия - записи в `previousByInstance`. - - При смене `InstanceId` 0→real в том же слоте (открытие урона) сохранить - карту, обновить art/bind/face state; не отправлять её в полёт из колоды. - - При исчезновении ключа освобождать карту как раньше. - - Реализация: не выполнена. - - Проверка: не выполнена. + - Добавлена ветка reuse-by-key до поиска по `InstanceId`. + - Переиспользуется `Card3D` по `spec.Key`, если старая/новая карта анонимна + или совпадают реальные `InstanceId`; иначе используется `previousByInstance` + или `RentCard`. + - При смене `0→real` в том же слоте карта сохраняется и обновляется + `Bind`/`SetArt`/`SetPose` без анимации. + - Карты с исчезнувшими ключами освобождаются через `ReleaseCard`. + - Реализация: выполнена. + - Проверка: `Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves`, + `Revealing_Face_Down_Damage_Reuses_Card_And_Stays_In_Damage_Zone` — проходят. - Зависит от: C2, C4. -- [ ] **C2. `MODIFY` `GameBoard3D.AddOpponentHandSpecs` / +- [x] **C2. `MODIFY` `GameBoard3D.AddOpponentHandSpecs` / `AddDamageSpecs`** — стабильные ключи и явная политика анимации. - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`. - - Оставить `OH:player:i` для скрытой руки и `D:player:i` для слота урона; - не использовать `InstanceId=0` как личность. - - Для скрытых карт при стабильном ключе не анимировать; при добавлении - новой скрытой карты в конец — разрешить один переход из Deck. - - Для урона: смена 0↔real или real→real в том же слоте — обновление - карты/переворот, не новый полёт из Deck. - - Реализация: не выполнена. - - Проверка: не выполнена. + - Ключи `OH:player:i` и `D:player:i` оставлены; `InstanceId=0` не считается + личностью. + - Скрытые карты при стабильном ключе не анимируются; новая скрытая карта в + конце анимируется из Deck. + - Для урона: смена `0↔real` или `real→real` в том же слоте обрабатывается + reuse-by-key, а не полётом из Deck. + - Реализация: выполнена. + - Проверка: см. C1 + `Board_Renders_Field_Hand_Guardian_Damage_And_Soul`. - Зависит от: C1. -- [ ] **C3. `MODIFY` `GameBoard3D` — карта источника из предыдущего +- [x] **C3. `MODIFY` `GameBoard3D` — карта источника из предыдущего `MatchView`.** - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`. - - Сохранять предыдущий `MatchView`/его зоны до присваивания нового - `_view`. - - Построить `Dictionary` для `Hand`, `Guardian`, `Soul`, - `Drop`, `Trigger`, `FaceUpDamage` (и при необходимости field), где - `SourcePose` содержит position/rotation/scale/faceUp. - - `SourcePose(spec)`: приоритет — текущая `Card3D` (для transfer), затем - previous-view зона по `InstanceId`, затем `DeckPosition`. + - Сохраняются `_previousView` и `_sourceByInstance` до замены `_view`. + - Построен `Dictionary` для `Soul`, `Drop`, + `FaceUpDamage`; источники `Hand`/`Guardian`/`Trigger`/`Field` покрываются + существующими `Card3D` через `previousByInstance`. + - `ResolveSourcePose(spec)`: приоритет — `Card3D` (transfer), затем зона из + `_sourceByInstance`, затем `DeckPosition` с рубашкой вверх. - Источники: - - Deck: `DeckPosition`, рубашка вверх (rotation с X-flip); + - Deck: `DeckPosition`, рубашка вверх (`baseYaw * X-flip 180°`); - Soul: `SoulPosition`, лицом вверх; - Drop: `DropPosition`, лицом вверх; - - Guardian: фактическая `Card3D` или `GuardianPosition`, +90°; - - Trigger: фактическая `Card3D` или `TriggerPosition`, ±90°; - FaceUpDamage: `DamagePosition`, лицом вверх и на боку; - - Hand: карта в руке, лицом вверх; - - Field: фактическая `Card3D`/`CirclePosition`. - - Не придумывать источник, если `InstanceId` нет в previous view и он не - Deck: fallback Deck допустим только с комментарием/тестом. - - `PlaceImmediate` для нового источника должен получать rotation с - правильным flip, а не только yaw. - - Реализация: не выполнена. - - Проверка: не выполнена. + - Hand/Guardian/Trigger/Field — через `previousByInstance`/transfer. + - Fallback Deck используется только с комментарием и покрывается тестами. + - `PlaceImmediate` получает rotation с учётом flip. + - Реализация: выполнена. + - Проверка: `New_Field_Unit_Starts_From_Hand_Not_Deck` — проходит. - Зависит от: C1, C2. -- [ ] **C4. `MODIFY` `CardMoveAnimator.Enqueue`** — один активный/queued +- [x] **C4. `MODIFY` `CardMoveAnimator.Enqueue`** — один активный/queued request на карту. - Файл: `client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs`. - - Перед постановкой нового request отменять/удалять старый request этой же - `Card3D` (queued и active), не трогая другие карты. - - Новый request должен начинаться с текущей визуальной позиции карты, а не - со старого `FromPosition`; если target совпадает — не создавать запрос. - - Не допускать дублей в `_active`; сохранить timeout/`CancelCard`/ - `CancelAll` и снятие `card-move`-лока. - - Реализация: не выполнена. - - Проверка: не выполнена. + - В начале `Enqueue` вызывается `CancelCard(card)`, что удаляет queued/ + active request этой же карты, не трогая другие. + - Источник (`from*`) передаётся из `GameBoard3D` как текущая визуальная + позиция карты; target проверяется в `Card3D.StartArcMove`. + - `CancelCard`, `CancelAll`, timeout и снятие `card-move`-лока сохранены. + - Реализация: выполнена. + - Проверка: `CardMoveAnimator_Coalesces_Requests_For_Same_Card`, + `CardMoveAnimator_Releases_Input_Lock_When_Card_Is_Cancelled` — проходят. - Зависит от: C1. -- [ ] **C5. `MODIFY` `GameBoard3D.Render` + `BattleView.Render`** — явно +- [x] **C5. `MODIFY` `GameBoard3D.Render` + `BattleView.Render`** — явно различать snapshot-рендер и локальный UI-рендер. - Файлы: `GameBoard3D.cs`, `client/Assets/Vanguard/Runtime/UI/BattleView.cs`. - - Добавить параметр `animateMoves` (или эквивалентный options-объект) без - публичного изменения серверных DTO. - - `OnSnapshot` → `animateMoves: true`. - - Локальные `Render()` (guard selection, card menu, status, cost counter) → - `animateMoves: false`: обновлять highlight/UI, но не запускать новые - move-анимации и не менять идентичность карт. - - Если при локальном Render карта реально новая (не должно происходить), - поставить в target pose без анимации и записать warning в debug-лог. - - Реализация: не выполнена. - - Проверка: не выполнена. + - `GameBoard3D.Render` получил параметр `bool animateMoves = true` без + изменения DTO. + - `BattleView.Render(bool animateMoves = true)`; `OnSnapshot` вызывает + `Render()` (`true`); все локальные вызовы заменены на + `Render(animateMoves: false)`. + - При `animateMoves: false` move-анимации не запускаются; идентичность карт + не меняется. Появление реально новой карты логирует warning. + - Реализация: выполнена. + - Проверка: `Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves`, + `ClientSmokeTests.Battle_Renders_3D_Damage_And_Rested_Cards` — проходят. - Зависит от: C1, C2. -- [ ] **C6. `MODIFY` `Card3D` / `GameBoard3D.UpdateHandView`** — не - перетирать движение руки. - - Файлы: `client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs`, - `GameBoard3D.cs`. - - Проверить сценарий «карта летит из Deck в Hand»: `UpdateHandView` - вызывает `SetPose` каждый кадр; target должен совпадать с target request. - - Если подтверждено расхождение — обновлять target request при изменении - hand pose или не вызывать `SetPose` для карты, которая ещё в - `CardMoveAnimator.IsBusy` для этого instance. - - Не отключать руку от камеры и не менять `GetHandViewPose` без - необходимости. - - Реализация: не выполнена. - - Проверка: не выполнена. +- [x] **C6. `MODIFY` `GameBoard3D.UpdateHandView`** — не перетирать движение + руки. + - Файл: `client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs`. + - В `UpdateHandView` добавлено условие: пропускать карту, если + `card.IsMoving`, чтобы `SetPose` не менял target во время arc-перехода. + - `GetHandViewPose` и привязка руки к камере не изменены. + - Реализация: выполнена. + - Проверка: `Hand_Is_Anchored_To_Camera_Bottom_Center_And_Faces_Player`, + `New_Field_Unit_Starts_From_Hand_Not_Deck` — проходят. - Зависит от: C3, C4. -- [ ] **C7. `MODIFY` `AnimationConfig.asset` + проверка значений.** +- [x] **C7. `MODIFY` `AnimationConfig.asset` + проверка значений.** - Файл: `client/Assets/Vanguard/Resources/AnimationConfig.asset`. - - Добавить/сериализовать `moveDuration`, `importantMoveDuration`, - `moveDelayBetweenCards`, `arcHeight`, camera/grow/dash-поля из - `AnimationConfig.cs`. - - Зафиксировать разумные значения: движение ~0.4–0.6, важное ~0.8–1.0, - stagger ~0.15–0.25, arc ~0.3–0.5; не менять длительности feedback без - запроса. - - Проверить в Unity Inspector, что значения не нулевые и совпадают с кодом. - - Реализация: не выполнена. - - Проверка: не выполнена. + - Добавлены/сериализованы все поля из `AnimationConfig.cs`: + `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`. + - Длительности feedback не менялись. + - Реализация: выполнена. + - Проверка: `CardOverlayTests.Animation_Config_Asset_Is_Loaded_From_Resources`, + поле `arcHeight` читается в `GameBoard3D.Render` — проходят. - Зависит от: C5 (можно параллельно). -- [ ] **C8. `TEST` `GameBoard3DTests`** — регрессии анимаций. +- [x] **C8. `TEST` `GameBoard3DTests`** — регрессии анимаций. - Файл: `client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs`. - - Добавить тесты: - 1. повторный `Render` с тем же view/state не увеличивает число - `Card3D` для opponent hand/face-down damage; - 2. повторный `Render` не создаёт новый `CardMoveAnimator` request для - стабильного ключа; - 3. открытие face-down damage переиспользует `Card3D` и не отправляет его - из Deck; - 4. новый unit из hand сохраняет source-позицию руки, а не Deck; - 5. `CardMoveAnimator` coalesce: второй Enqueue той же карты не оставляет - дубль в active/queue; - 6. `CancelCard` по-прежнему освобождает `card-move`-лок. - - Для проверки source/queue при необходимости добавить **тестовый seam** - (read-only snapshot последних запросов/позиций) в `CardMoveAnimator` или - `GameBoard3D`, не меняя публичное поведение. - - Реализация: не выполнена. - - Проверка: не выполнена. + - Добавлены тесты: + 1. `Repeated_Render_Reuses_Hidden_Cards_And_Does_Not_Enqueue_Moves`; + 2. `Revealing_Face_Down_Damage_Reuses_Card_And_Stays_In_Damage_Zone`; + 3. `New_Field_Unit_Starts_From_Hand_Not_Deck`; + 4. `CardMoveAnimator_Coalesces_Requests_For_Same_Card`. + - В `CardMoveAnimator` добавлены read-only seam-свойства: + `PendingCount`, `ActiveCount`, `IsCardQueued`, `IsCardActive`. + - Исправлен `ClientSmokeTests.Battle_Renders_3D_Damage_And_Rested_Cards` + для вызова `Render` с новой сигнатурой через reflection. + - Реализация: выполнена. + - Проверка: все новые тесты + старые — 46/46. - Зависит от: C1–C6. -- [ ] **C9. `VERIFY` smoke / compile / EditMode.** - - Запустить EditMode в копии проекта без `Library`. - - `PLAYMAT_SMOKE`; `SCENE_SMOKE` — если затронуты сцены/сборка. - - Проверить отсутствие ошибок компиляции и предупреждений о missing - serialized fields. - - Реализация: не выполнена. - - Проверка: не выполнена. +- [x] **C9. `VERIFY` smoke / compile / EditMode.** + - Скопирован проект без `Library`; + `Unity 6000.6.6f1 -batchmode -nographics -runTests -testPlatform EditMode`. + - EditMode: **46/46 passed** (включая 15 `GameBoard3DTests`, 12 + `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. - [ ] **C10. `VERIFY` ручная проверка пользователем.** @@ -380,8 +371,8 @@ повторные клики по UI во время/после анимации. - Ожидаемо: нет полёта скрытых карт из колоды при каждом клике; источник совпадает с зоной; нет рывков/двойных стартов; ввод не зависает. - - Реализация: не выполнена. - - Проверка: не выполнена — только пользователь. + - Реализация: не применима. + - Проверка: остаётся на пользователе. - Зависит от: C9. #### D. Последовательность выполнения @@ -444,9 +435,12 @@ #### G. Результат -- **Реализовано:** не начато. -- **Проверки:** не выполнены. -- **Не завершено/заблокировано:** ожидает этапа Kimi; критических вопросов - пользователю на данный момент нет. -- **Skill обновлён:** не требуется до подтверждения новых постоянных правил. -- **Git:** изменён только план/документация, код не менялся. +- **Реализовано:** C1–C8 (см. чек-лист). +- **Проверки:** EditMode 46/46 в копии без `Library`; компиляция без ошибок. +- **Не завершено/заблокировано:** C10 — ручная проверка пользователем. +- **Skill обновлён:** не требуется; изменения технические, не затрагивают + подтверждённые правила/архитектуру. +- **Git:** будут изменены `GameBoard3D.cs`, `CardMoveAnimator.cs`, + `BattleView.cs`, `AnimationConfig.asset`, `GameBoard3DTests.cs`, + `ClientSmokeTests.cs`, `IMPLEMENTATION_PLAN.md`, `docs/SESSION-STATE.md`, + `docs/HANDOFF.md`. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index e9b50fc..2e79cfd 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 — защита и оплата способностей - Панель «Фазы» переименована в «Действия»; защита перенесена туда. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index b7c3a27..7a7a18a 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -273,14 +273,14 @@ Standalone Windows: -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **42/42** EditMode (сборка всех экранов, импорт арта, -перевод статических/динамических строк, live-переключение языка, рендер -3D-доски с уроном и отдыхающей картой, геометрия поля, правила +Клиентские проверки: **46/46** EditMode (включая новые тесты TASK-2026-001: +повторный render скрытых карт, открытие урона, source-позиция нового юнита, +coalescing `CardMoveAnimator`; сборка всех экранов, импорт арта, перевод +строк, live-переключение языка, рендер 3D-доски, геометрия поля, правила перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, CardListView/Detail, зоны и рука противника, InputLock, контекстное меню, стрелка атаки, prefab-оверлеи, раскладка на 1920×1080, авто-завершение -муллигана без кнопки после обмена, одна кнопка защиты в панели действий, -отсутствие дублей карт в Guardian), `SCENE_SMOKE +муллигана, одна кнопка защиты, отсутствие дублей в Guardian), `SCENE_SMOKE success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`. `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 Soul Blast/прочие стоимости, отдельный `CostPaymentOverlay`, world-space Power/Shield (верхний strip принят как HUD), world-space dash shader; полировка позиций на конкретном мониторе. -2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты. -3. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost. -4. Звук и настройки, DOTween. -5. Лаунчер WPF. +3. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты. +4. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost. +5. Звук и настройки, DOTween. +6. Лаунчер WPF. Тренировка с ботом и боевой UI с артами/анимациями/локализацией уже есть.