fix: prevent stuck card-move lock and duplicate cards on swaps
CI / test (push) Successful in 5m15s
Details
CI / test (push) Successful in 5m15s
Details
This commit is contained in:
parent
3c7f8645b2
commit
fd21a969fa
|
|
@ -21,8 +21,14 @@ namespace Vanguard.Board3D
|
||||||
public float ArcHeight;
|
public float ArcHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class ActiveMove
|
||||||
|
{
|
||||||
|
public Card3D Card;
|
||||||
|
public float Remaining;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly Queue<Request> _queue = new Queue<Request>();
|
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 readonly InputLockService _inputLock;
|
||||||
private AnimationConfig _config;
|
private AnimationConfig _config;
|
||||||
private float _delay;
|
private float _delay;
|
||||||
|
|
@ -70,10 +76,35 @@ namespace Vanguard.Board3D
|
||||||
{
|
{
|
||||||
if (_active.Count > 0)
|
if (_active.Count > 0)
|
||||||
{
|
{
|
||||||
|
var delta = Mathf.Max(0f, deltaTime);
|
||||||
for (var i = _active.Count - 1; i >= 0; i--)
|
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);
|
_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)
|
public void CancelAll(bool snapCards = false)
|
||||||
{
|
{
|
||||||
_queue.Clear();
|
_queue.Clear();
|
||||||
foreach (var card in _active)
|
foreach (var active in _active)
|
||||||
{
|
{
|
||||||
if (card != null)
|
if (active?.Card != null)
|
||||||
card.StopMove(snapCards);
|
active.Card.StopMove(snapCards);
|
||||||
}
|
}
|
||||||
|
|
||||||
_active.Clear();
|
_active.Clear();
|
||||||
|
|
@ -102,6 +133,35 @@ namespace Vanguard.Board3D
|
||||||
ReleaseLock();
|
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()
|
private void StartNext()
|
||||||
{
|
{
|
||||||
if (_queue.Count == 0)
|
if (_queue.Count == 0)
|
||||||
|
|
@ -123,7 +183,14 @@ namespace Vanguard.Board3D
|
||||||
request.ArcHeight);
|
request.ArcHeight);
|
||||||
|
|
||||||
if (card.IsMoving)
|
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
|
_delay = Mathf.Max(0.01f, _config != null
|
||||||
? _config.moveDelayBetweenCards
|
? _config.moveDelayBetweenCards
|
||||||
: 0.18f);
|
: 0.18f);
|
||||||
|
|
|
||||||
|
|
@ -278,55 +278,90 @@ namespace Vanguard.Board3D
|
||||||
previousKeyByInstance[previous.Ref.InstanceId] = pair.Key;
|
previousKeyByInstance[previous.Ref.InstanceId] = pair.Key;
|
||||||
}
|
}
|
||||||
|
|
||||||
var used = new HashSet<string>(StringComparer.Ordinal);
|
|
||||||
var immediateAll = !_hasRendered;
|
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)
|
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;
|
if (spec.Ref.InstanceId != 0
|
||||||
var existingKey = _cards.TryGetValue(spec.Key, out var card)
|
&& previousByInstance.TryGetValue(
|
||||||
&& card != null;
|
spec.Ref.InstanceId,
|
||||||
var sameInstance = existingKey
|
out var previous)
|
||||||
&& card.Ref.InstanceId == instanceId;
|
&& assignedInstances.Add(spec.Ref.InstanceId))
|
||||||
var transfer = false;
|
|
||||||
|
|
||||||
if (!sameInstance
|
|
||||||
&& instanceId != 0
|
|
||||||
&& previousByInstance.TryGetValue(instanceId, out var previous))
|
|
||||||
{
|
{
|
||||||
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;
|
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();
|
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;
|
foreach (var pair in _cards)
|
||||||
var fromPosition = card.transform.position;
|
{
|
||||||
var fromRotation = card.transform.rotation;
|
if (!assignedCards.Contains(pair.Value))
|
||||||
var fromScale = card.transform.localScale;
|
ReleaseCard(pair.Value);
|
||||||
var fromInstance = card.Ref.InstanceId;
|
}
|
||||||
var fromZone = card.Ref.Zone;
|
|
||||||
|
_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.Bind(spec.Ref);
|
||||||
card.SetArt(spec.Art.Front, spec.Art.Accent);
|
card.SetArt(spec.Art.Front, spec.Art.Accent);
|
||||||
|
|
@ -339,39 +374,8 @@ namespace Vanguard.Board3D
|
||||||
spec.DamageLayout,
|
spec.DamageLayout,
|
||||||
spec.Scale * Mathf.Max(0.05f, _config.cardScale));
|
spec.Scale * Mathf.Max(0.05f, _config.cardScale));
|
||||||
|
|
||||||
var moved = !immediateAll
|
|
||||||
&& (!wasActive
|
|
||||||
|| transfer
|
|
||||||
|| !existingKey
|
|
||||||
|| fromInstance != instanceId
|
|
||||||
|| fromZone != spec.Ref.Zone);
|
|
||||||
|
|
||||||
if (moved)
|
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
|
var important = spec.Ref.Zone is
|
||||||
BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop;
|
BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop;
|
||||||
var duration = important
|
var duration = important
|
||||||
|
|
@ -386,9 +390,9 @@ namespace Vanguard.Board3D
|
||||||
: 0.45f;
|
: 0.45f;
|
||||||
_moveAnimator.Enqueue(
|
_moveAnimator.Enqueue(
|
||||||
card,
|
card,
|
||||||
sourcePosition,
|
assignment.FromPosition,
|
||||||
sourceRotation,
|
assignment.FromRotation,
|
||||||
sourceScale,
|
assignment.FromScale,
|
||||||
duration,
|
duration,
|
||||||
arc);
|
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);
|
UpdatePiles(view);
|
||||||
UpdateSlots(state);
|
UpdateSlots(state);
|
||||||
UpdateAttackArrowState(view);
|
UpdateAttackArrowState(view);
|
||||||
|
|
@ -1632,6 +1623,8 @@ namespace Vanguard.Board3D
|
||||||
{
|
{
|
||||||
if (card == null)
|
if (card == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
_moveAnimator?.CancelCard(card);
|
||||||
card.SetHighlight(null);
|
card.SetHighlight(null);
|
||||||
card.gameObject.SetActive(false);
|
card.gameObject.SetActive(false);
|
||||||
_pool.Push(card);
|
_pool.Push(card);
|
||||||
|
|
@ -1777,6 +1770,16 @@ namespace Vanguard.Board3D
|
||||||
public int Count { get; }
|
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
|
private sealed class CardSpec
|
||||||
{
|
{
|
||||||
public string Key;
|
public string Key;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using Vanguard.Battle;
|
||||||
using Vanguard.Board3D;
|
using Vanguard.Board3D;
|
||||||
using Vanguard.Shared.Protocol;
|
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]
|
[Test]
|
||||||
public void Card_Arc_Move_Interpolates_And_Finishes_At_Target()
|
public void Card_Arc_Move_Interpolates_And_Finishes_At_Target()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@
|
||||||
## Статус реализации (2026-10-24)
|
## Статус реализации (2026-10-24)
|
||||||
|
|
||||||
- **P0 готово:** `CardMoveAnimator` + дуга/scale/rotation, stagger и
|
- **P0 готово:** `CardMoveAnimator` + дуга/scale/rotation, stagger и
|
||||||
`InputLock`; diff по `InstanceId` с переносом пула; `AnimationConfig`
|
`InputLock`; diff по `InstanceId` с переносом пула (включая обмен двух
|
||||||
расширен; ориентация Trigger Zone ±90°; `DropZoneFaceDown`.
|
кругов без освобождения/дублей); таймаут/`CancelCard` не дают зависнуть
|
||||||
|
`card-move`-локу; `AnimationConfig` расширен; ориентация Trigger Zone
|
||||||
|
±90°; `DropZoneFaceDown`.
|
||||||
- **P0 готово:** `CameraDirector` и `TriggerCheckSequence` (zoom → hold →
|
- **P0 готово:** `CameraDirector` и `TriggerCheckSequence` (zoom → hold →
|
||||||
return, цепочка, восстановление pose).
|
return, цепочка, восстановление pose).
|
||||||
- **P1 частично:** `ArrowConfig`, общий `ArrowDirector`, красная attack и
|
- **P1 частично:** `ArrowConfig`, общий `ArrowDirector`, красная attack и
|
||||||
|
|
|
||||||
|
|
@ -1107,3 +1107,12 @@ Battle; Boot и общие сервисы остаются персистент
|
||||||
в панели «Действия».
|
в панели «Действия».
|
||||||
- Проверки: правила 151, сервер 45, клиентские EditMode 40/40; `validate`,
|
- Проверки: правила 151, сервер 45, клиентские EditMode 40/40; `validate`,
|
||||||
`translations check`, standalone build; сервер задеплоен.
|
`translations check`, standalone build; сервер задеплоен.
|
||||||
|
## 2026-10-24 — исправление зависшего InputLock
|
||||||
|
|
||||||
|
- `GameBoard3D.Render` сначала распределяет старые `Card3D` по `InstanceId`,
|
||||||
|
затем освобождает лишние. Обмен двух RC больше не освобождает карту,
|
||||||
|
которая должна переехать в другой ключ (иначе дубли/неактивные карты).
|
||||||
|
- `CardMoveAnimator` получил дедлайн активной анимации и `CancelCard`;
|
||||||
|
`ReleaseCard` отменяет движение. Это устраняет вечный `card-move`-лок,
|
||||||
|
из-за которого не открывалась смена и поле выглядело зависшим.
|
||||||
|
- Клиентские EditMode: **42/42**.
|
||||||
|
|
@ -270,7 +270,7 @@ Standalone Windows:
|
||||||
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
||||||
```
|
```
|
||||||
|
|
||||||
Клиентские проверки: **40/40** EditMode (сборка всех экранов, импорт арта,
|
Клиентские проверки: **42/42** EditMode (сборка всех экранов, импорт арта,
|
||||||
перевод статических/динамических строк, live-переключение языка, рендер
|
перевод статических/динамических строк, live-переключение языка, рендер
|
||||||
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
|
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
|
||||||
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
|
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue