From fd21a969fa686d944da2c9ec417f9477ce563e9f Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sat, 24 Oct 2026 02:12:52 +0500 Subject: [PATCH] fix: prevent stuck card-move lock and duplicate cards on swaps --- .../Board3D/Animation/CardMoveAnimator.cs | 79 +++++++- .../Runtime/Board3D/View/GameBoard3D.cs | 169 +++++++++--------- .../Vanguard/Tests/Editor/GameBoard3DTests.cs | 89 +++++++++ docs/3d-ux-backlog.md | 6 +- docs/HANDOFF.md | 11 +- docs/SESSION-STATE.md | 2 +- 6 files changed, 263 insertions(+), 93 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs index 1ea5082..5764fa0 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs @@ -21,8 +21,14 @@ namespace Vanguard.Board3D public float ArcHeight; } + private sealed class ActiveMove + { + public Card3D Card; + public float Remaining; + } + private readonly Queue _queue = new Queue(); - private readonly List _active = new List(); + private readonly List _active = new List(); private readonly InputLockService _inputLock; private AnimationConfig _config; private float _delay; @@ -70,10 +76,35 @@ namespace Vanguard.Board3D { if (_active.Count > 0) { + var delta = Mathf.Max(0f, deltaTime); for (var i = _active.Count - 1; i >= 0; i--) { - if (_active[i] == null || !_active[i].IsMoving) + var active = _active[i]; + var card = active?.Card; + if (active == null + || card == null + || !card.gameObject.activeInHierarchy) + { + if (card != null) + card.StopMove(snap: false); _active.RemoveAt(i); + continue; + } + + active.Remaining -= delta; + if (!card.IsMoving) + { + _active.RemoveAt(i); + continue; + } + + // Страховка: карта могла быть снята с рендера до + // завершения Tick. Не держим InputLock вечно. + if (active.Remaining <= 0f) + { + card.StopMove(snap: true); + _active.RemoveAt(i); + } } } @@ -91,10 +122,10 @@ namespace Vanguard.Board3D public void CancelAll(bool snapCards = false) { _queue.Clear(); - foreach (var card in _active) + foreach (var active in _active) { - if (card != null) - card.StopMove(snapCards); + if (active?.Card != null) + active.Card.StopMove(snapCards); } _active.Clear(); @@ -102,6 +133,35 @@ namespace Vanguard.Board3D ReleaseLock(); } + public void CancelCard(Card3D card) + { + if (card == null) + return; + + var remaining = new Queue(); + while (_queue.Count > 0) + { + var request = _queue.Dequeue(); + if (request.Card != card) + remaining.Enqueue(request); + } + + while (remaining.Count > 0) + _queue.Enqueue(remaining.Dequeue()); + + for (var i = _active.Count - 1; i >= 0; i--) + { + if (_active[i]?.Card != card) + continue; + + card.StopMove(snap: false); + _active.RemoveAt(i); + } + + if (!IsBusy) + ReleaseLock(); + } + private void StartNext() { if (_queue.Count == 0) @@ -123,7 +183,14 @@ namespace Vanguard.Board3D request.ArcHeight); if (card.IsMoving) - _active.Add(card); + { + _active.Add(new ActiveMove + { + Card = card, + Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f, + }); + } + _delay = Mathf.Max(0.01f, _config != null ? _config.moveDelayBetweenCards : 0.18f); diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index e389193..ad9129f 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -278,55 +278,90 @@ namespace Vanguard.Board3D previousKeyByInstance[previous.Ref.InstanceId] = pair.Key; } - var used = new HashSet(StringComparer.Ordinal); var immediateAll = !_hasRendered; + var assignedInstances = new HashSet(); + var assignedCards = new HashSet(); + var assignments = new Dictionary( + StringComparer.Ordinal); + // Сначала распределяем старые Card3D по InstanceId. Нельзя + // освобождать карту из занятого ключа: она может участвовать + // в обмене двух кругов и должна переехать в другой ключ. foreach (var spec in specs) { - used.Add(spec.Key); + var transferred = false; + string oldKey = null; + var fromPosition = default(Vector3); + var fromRotation = default(Quaternion); + var fromScale = default(Vector3); + Card3D card = null; - var instanceId = spec.Ref.InstanceId; - var existingKey = _cards.TryGetValue(spec.Key, out var card) - && card != null; - var sameInstance = existingKey - && card.Ref.InstanceId == instanceId; - var transfer = false; - - if (!sameInstance - && instanceId != 0 - && previousByInstance.TryGetValue(instanceId, out var previous)) + if (spec.Ref.InstanceId != 0 + && previousByInstance.TryGetValue( + spec.Ref.InstanceId, + out var previous) + && assignedInstances.Add(spec.Ref.InstanceId)) { - if (_cards.TryGetValue(spec.Key, out var displaced) - && displaced != null - && displaced != previous) - { - ReleaseCard(displaced); - } - - if (previousKeyByInstance.TryGetValue(instanceId, out var oldKey) - && oldKey != spec.Key) - { - _cards.Remove(oldKey); - } - card = previous; - transfer = true; + transferred = true; + previousKeyByInstance.TryGetValue( + spec.Ref.InstanceId, + out oldKey); + fromPosition = card.transform.position; + fromRotation = card.transform.rotation; + fromScale = card.transform.localScale; } - else if (!existingKey || !sameInstance) + else { - if (existingKey && card != null) - ReleaseCard(card); card = RentCard(); } - _cards[spec.Key] = card; + assignedCards.Add(card); + assignments[spec.Key] = new CardAssignment + { + Card = card, + Transferred = transferred, + OldKey = oldKey, + FromPosition = fromPosition, + FromRotation = fromRotation, + FromScale = fromScale, + }; + } - var wasActive = card.gameObject.activeSelf; - var fromPosition = card.transform.position; - var fromRotation = card.transform.rotation; - var fromScale = card.transform.localScale; - var fromInstance = card.Ref.InstanceId; - var fromZone = card.Ref.Zone; + foreach (var pair in _cards) + { + if (!assignedCards.Contains(pair.Value)) + ReleaseCard(pair.Value); + } + + _cards.Clear(); + foreach (var pair in assignments) + _cards[pair.Key] = pair.Value.Card; + + foreach (var spec in specs) + { + var assignment = assignments[spec.Key]; + var card = assignment.Card; + var moved = !immediateAll + && (!assignment.Transferred + || !string.Equals( + assignment.OldKey, + spec.Key, + StringComparison.Ordinal)); + + if (moved && !assignment.Transferred) + { + var source = SourcePose(spec); + assignment.FromPosition = source.Position; + assignment.FromRotation = source.Rotation; + assignment.FromScale = Vector3.one * Mathf.Max( + 0.05f, + spec.Scale * _config.cardScale); + card.PlaceImmediate( + assignment.FromPosition, + assignment.FromRotation, + assignment.FromScale); + } card.Bind(spec.Ref); card.SetArt(spec.Art.Front, spec.Art.Accent); @@ -339,39 +374,8 @@ namespace Vanguard.Board3D spec.DamageLayout, spec.Scale * Mathf.Max(0.05f, _config.cardScale)); - var moved = !immediateAll - && (!wasActive - || transfer - || !existingKey - || fromInstance != instanceId - || fromZone != spec.Ref.Zone); - if (moved) { - Vector3 sourcePosition; - Quaternion sourceRotation; - Vector3 sourceScale; - if (transfer - || (existingKey && fromInstance == instanceId)) - { - sourcePosition = fromPosition; - sourceRotation = fromRotation; - sourceScale = fromScale; - } - else - { - var source = SourcePose(spec); - sourcePosition = source.Position; - sourceRotation = source.Rotation; - sourceScale = Vector3.one * Mathf.Max( - 0.05f, - spec.Scale * _config.cardScale); - card.PlaceImmediate( - sourcePosition, - sourceRotation, - sourceScale); - } - var important = spec.Ref.Zone is BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop; var duration = important @@ -386,9 +390,9 @@ namespace Vanguard.Board3D : 0.45f; _moveAnimator.Enqueue( card, - sourcePosition, - sourceRotation, - sourceScale, + assignment.FromPosition, + assignment.FromRotation, + assignment.FromScale, duration, arc); } @@ -405,19 +409,6 @@ namespace Vanguard.Board3D } } - if (_cards.Count > 0) - { - var stale = _cards - .Where(pair => !used.Contains(pair.Key)) - .Select(pair => pair.Key) - .ToList(); - foreach (var key in stale) - { - ReleaseCard(_cards[key]); - _cards.Remove(key); - } - } - UpdatePiles(view); UpdateSlots(state); UpdateAttackArrowState(view); @@ -1632,6 +1623,8 @@ namespace Vanguard.Board3D { if (card == null) return; + + _moveAnimator?.CancelCard(card); card.SetHighlight(null); card.gameObject.SetActive(false); _pool.Push(card); @@ -1777,6 +1770,16 @@ namespace Vanguard.Board3D public int Count { get; } } + private sealed class CardAssignment + { + public Card3D Card; + public bool Transferred; + public string OldKey; + public Vector3 FromPosition; + public Quaternion FromRotation; + public Vector3 FromScale; + } + private sealed class CardSpec { public string Key; diff --git a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs index c9454a2..ccbaab4 100644 --- a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using UnityEngine; +using Vanguard.Battle; using Vanguard.Board3D; using Vanguard.Shared.Protocol; @@ -311,6 +312,94 @@ namespace Vanguard.Tests } } + [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() { diff --git a/docs/3d-ux-backlog.md b/docs/3d-ux-backlog.md index 4a85370..4bb64b1 100644 --- a/docs/3d-ux-backlog.md +++ b/docs/3d-ux-backlog.md @@ -6,8 +6,10 @@ ## Статус реализации (2026-10-24) - **P0 готово:** `CardMoveAnimator` + дуга/scale/rotation, stagger и - `InputLock`; diff по `InstanceId` с переносом пула; `AnimationConfig` - расширен; ориентация Trigger Zone ±90°; `DropZoneFaceDown`. + `InputLock`; diff по `InstanceId` с переносом пула (включая обмен двух + кругов без освобождения/дублей); таймаут/`CancelCard` не дают зависнуть + `card-move`-локу; `AnimationConfig` расширен; ориентация Trigger Zone + ±90°; `DropZoneFaceDown`. - **P0 готово:** `CameraDirector` и `TriggerCheckSequence` (zoom → hold → return, цепочка, восстановление pose). - **P1 частично:** `ArrowConfig`, общий `ArrowDirector`, красная attack и diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index fd92aeb..77810d6 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1106,4 +1106,13 @@ Battle; Boot и общие сервисы остаются персистент `GameEngine.CounterBlast`; клиентский выбор нескольких карт урона и счётчик в панели «Действия». - Проверки: правила 151, сервер 45, клиентские EditMode 40/40; `validate`, - `translations check`, standalone build; сервер задеплоен. \ No newline at end of file + `translations check`, standalone build; сервер задеплоен. +## 2026-10-24 — исправление зависшего InputLock + +- `GameBoard3D.Render` сначала распределяет старые `Card3D` по `InstanceId`, + затем освобождает лишние. Обмен двух RC больше не освобождает карту, + которая должна переехать в другой ключ (иначе дубли/неактивные карты). +- `CardMoveAnimator` получил дедлайн активной анимации и `CancelCard`; + `ReleaseCard` отменяет движение. Это устраняет вечный `card-move`-лок, + из-за которого не открывалась смена и поле выглядело зависшим. +- Клиентские EditMode: **42/42**. \ No newline at end of file diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index 5a4070a..1e1c5ac 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -270,7 +270,7 @@ Standalone Windows: -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **40/40** EditMode (сборка всех экранов, импорт арта, +Клиентские проверки: **42/42** EditMode (сборка всех экранов, импорт арта, перевод статических/динамических строк, live-переключение языка, рендер 3D-доски с уроном и отдыхающей картой, геометрия поля, правила перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,