fix: prevent stuck card-move lock and duplicate cards on swaps
CI / test (push) Successful in 5m15s Details

This commit is contained in:
MpakobecEx 2026-10-24 02:12:52 +05:00
parent 3c7f8645b2
commit fd21a969fa
6 changed files with 263 additions and 93 deletions

View File

@ -21,8 +21,14 @@ namespace Vanguard.Board3D
public float ArcHeight;
}
private sealed class ActiveMove
{
public Card3D Card;
public float Remaining;
}
private readonly Queue<Request> _queue = new Queue<Request>();
private readonly List<Card3D> _active = new List<Card3D>();
private readonly List<ActiveMove> _active = new List<ActiveMove>();
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<Request>();
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);

View File

@ -278,55 +278,90 @@ namespace Vanguard.Board3D
previousKeyByInstance[previous.Ref.InstanceId] = pair.Key;
}
var used = new HashSet<string>(StringComparer.Ordinal);
var immediateAll = !_hasRendered;
var assignedInstances = new HashSet<int>();
var assignedCards = new HashSet<Card3D>();
var assignments = new Dictionary<string, CardAssignment>(
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;

View File

@ -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()
{

View File

@ -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 и

View File

@ -1107,3 +1107,12 @@ Battle; Boot и общие сервисы остаются персистент
в панели «Действия».
- Проверки: правила 151, сервер 45, клиентские EditMode 40/40; `validate`,
`translations check`, standalone build; сервер задеплоен.
## 2026-10-24 — исправление зависшего InputLock
- `GameBoard3D.Render` сначала распределяет старые `Card3D` по `InstanceId`,
затем освобождает лишние. Обмен двух RC больше не освобождает карту,
которая должна переехать в другой ключ (иначе дубли/неактивные карты).
- `CardMoveAnimator` получил дедлайн активной анимации и `CancelCard`;
`ReleaseCard` отменяет движение. Это устраняет вечный `card-move`-лок,
из-за которого не открывалась смена и поле выглядело зависшим.
- Клиентские EditMode: **42/42**.

View File

@ -270,7 +270,7 @@ Standalone Windows:
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
```
Клиентские проверки: **40/40** EditMode (сборка всех экранов, импорт арта,
Клиентские проверки: **42/42** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,