client: TASK-2026-001 smooth card movement animation fixes
CI / test (push) Successful in 3m14s
Details
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:
parent
aac0268cf2
commit
29822be2ad
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
position,
|
0.05f,
|
||||||
rotation,
|
spec.Scale * _config.cardScale);
|
||||||
Vector3.one * Mathf.Max(
|
|
||||||
0.05f,
|
if (spec.Ref.InstanceId != 0
|
||||||
spec.Scale * _config.cardScale));
|
&& _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,
|
||||||
|
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)
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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 — защита и оплата способностей
|
||||||
|
|
||||||
- Панель «Фазы» переименована в «Действия»; защита перенесена туда.
|
- Панель «Фазы» переименована в «Действия»; защита перенесена туда.
|
||||||
|
|
|
||||||
|
|
@ -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 с артами/анимациями/локализацией уже есть.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue