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