client/server: анимации 3D-поля, HUD и правила (WIP)

- Board3D: CardMoveAnimator, Card3D, GameBoard3D, раскладка, слоты, меши

- Battle: AnimationConfig, CameraDirector, TriggerCheckSequence

- UI: BattleView, новый PlayerStatsPanelView

- Settings/ProjectSettings: URP-профиль и настройки проекта

- Rules/Shared: колонки Tejas и проекция матча

- тесты: GameBoard3D, ClientSmoke; док ANIMATION-INVENTORY
This commit is contained in:
MpakobecEx 2026-10-30 02:34:36 +05:00
parent 29822be2ad
commit 26934c9213
27 changed files with 3962 additions and 636 deletions

View File

@ -84,6 +84,7 @@ MonoBehaviour:
m_SupportScreenSpaceLensFlare: 1
m_GPUResidentDrawerMode: 0
m_SmallMeshScreenPercentage: 0
m_ShadowSmallMeshScreenPercentages: {x: 0, y: 0, z: 0, w: 0}
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
m_ShadowType: 1
m_LocalShadowsSupported: 0
@ -100,18 +101,18 @@ MonoBehaviour:
m_Keys: []
m_Values:
m_PrefilteringModeMainLightShadows: 3
m_PrefilteringModeAdditionalLight: 4
m_PrefilteringModeAdditionalLightShadows: 0
m_PrefilteringModeAdditionalLight: 0
m_PrefilteringModeAdditionalLightShadows: 2
m_PrefilterXRKeywords: 1
m_PrefilteringModeForwardPlus: 1
m_PrefilteringModeForwardPlus: 2
m_PrefilteringModeDeferredRendering: 0
m_PrefilteringModeScreenSpaceOcclusion: 1
m_PrefilteringModeScreenSpaceReflection: 1
m_PrefilteringModeScreenSpaceOcclusion: 2
m_PrefilteringModeScreenSpaceReflection: 0
m_PrefilterWriteSmoothness: 1
m_PrefilterDebugKeywords: 1
m_PrefilterWriteRenderingLayers: 0
m_PrefilterWriteRenderingLayers: 1
m_PrefilterHDROutput: 1
m_PrefilterAlphaOutput: 0
m_PrefilterAlphaOutput: 1
m_PrefilterSSAODepthNormals: 0
m_PrefilterSSAOSourceDepthLow: 1
m_PrefilterSSAOSourceDepthMedium: 1
@ -123,20 +124,23 @@ MonoBehaviour:
m_PrefilterSSAOSampleCountHigh: 1
m_PrefilterDBufferMRT1: 1
m_PrefilterDBufferMRT2: 1
m_PrefilterDBufferMRT3: 0
m_PrefilterSoftShadowsQualityLow: 0
m_PrefilterSoftShadowsQualityMedium: 0
m_PrefilterSoftShadowsQualityHigh: 0
m_PrefilterDBufferMRT3: 1
m_PrefilterSoftShadowsQualityLow: 1
m_PrefilterSoftShadowsQualityMedium: 1
m_PrefilterSoftShadowsQualityHigh: 1
m_PrefilterSoftShadows: 0
m_PrefilterScreenCoord: 1
m_PrefilterScreenSpaceIrradiance: 0
m_PrefilterScreenSpaceIrradiance: 1
m_PrefilterNativeRenderPass: 1
m_PrefilterUseLegacyLightmaps: 0
m_PrefilterBicubicLightmapSampling: 0
m_PrefilterBicubicLightmapSampling: 1
m_PrefilterReflectionProbeRotation: 0
m_PrefilterReflectionProbeBlending: 0
m_PrefilterReflectionProbeBoxProjection: 0
m_PrefilterReflectionProbeAtlas: 0
m_PrefilterPointSamplingUpsampling: 1
m_Light2DPrefilteringMode: 0
m_Light2DKeptVariantCombos: []
m_ShaderVariantLogLevel: 0
m_ShadowCascades: 0
m_Textures:

View File

@ -72,7 +72,22 @@ MonoBehaviour:
- rid: 6588237625532874761
- rid: 6588237625532874762
m_RuntimeSettings:
m_List: []
m_List:
- rid: 6852985685364965378
- rid: 6852985685364965380
- rid: 6852985685364965381
- rid: 6852985685364965384
- rid: 6852985685364965392
- rid: 6852985685364965394
- rid: 8712630790384254976
- rid: 4284062438471237637
- rid: 5747374308340793346
- rid: 5747374308340793347
- rid: 5747374308340793348
- rid: 6588237625532874753
- rid: 6588237625532874756
- rid: 6588237625532874759
- rid: 6588237625532874761
m_AssetVersion: 11
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames:

View File

@ -28,15 +28,25 @@ MonoBehaviour:
intercept: 1.9
perfectGuard: 2
inputLockTail: 0.15
eventPause: 1.5
postDrivePause: 1.5
postTriggerPause: 1.5
postDamagePause: 1.5
arrowShowDuration: 0.25
arrowHideDuration: 0.2
moveDuration: 0.5
importantMoveDuration: 0.9
moveDelayBetweenCards: 0.18
moveDuration: 0.8
importantMoveDuration: 1.3
moveDelayBetweenCards: 0.22
arcHeight: 0.45
cameraZoomDuration: 0.35
triggerRevealHold: 1.2
cameraReturnDuration: 0.4
introOverheadHold: 1.5
introCameraMoveDuration: 1
introMainHold: 1.5
introDrawDuration: 0.6
cameraZoomDuration: 0.5
vanguardRevealDuration: 0.8
triggerRevealDuration: 0.35
triggerRevealHold: 0.75
cameraReturnDuration: 0.5
cameraRezoomDuration: 0.3
growDuration: 0.35
dashScrollSpeed: 1.5

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6801c3b9bef9c1644a4c0af035547bd0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -18,15 +18,23 @@ namespace Vanguard.Battle
public float retireDuration = 0.8f;
[Header("3D UX: движение")]
public float moveDuration = 0.5f;
public float importantMoveDuration = 0.9f;
public float moveDelayBetweenCards = 0.18f;
public float moveDuration = 0.8f;
public float importantMoveDuration = 1.3f;
public float moveDelayBetweenCards = 0.22f;
public float arcHeight = 0.45f;
[Header("Старт боя")]
public float introOverheadHold = 1.5f;
public float introCameraMoveDuration = 1f;
public float introMainHold = 1.5f;
public float introDrawDuration = 0.6f;
[Header("3D UX: камера")]
public float cameraZoomDuration = 0.35f;
public float triggerRevealHold = 1.2f;
public float cameraReturnDuration = 0.4f;
public float cameraZoomDuration = 0.5f;
public float vanguardRevealDuration = 0.8f;
public float triggerRevealDuration = 0.35f;
public float triggerRevealHold = 0.75f;
public float cameraReturnDuration = 0.5f;
public float cameraRezoomDuration = 0.3f;
[Header("3D UX: стрелки")]
@ -50,6 +58,12 @@ namespace Vanguard.Battle
[Header("Ввод")]
public float inputLockTail = 0.15f;
[Header("Боевые паузы")]
public float eventPause = 1.5f;
public float postDrivePause = 1.5f;
public float postTriggerPause = 1.5f;
public float postDamagePause = 1.5f;
[Header("Стрелка атаки")]
public float arrowShowDuration = 0.25f;
public float arrowHideDuration = 0.2f;
@ -89,7 +103,13 @@ namespace Vanguard.Battle
importantMoveDuration = Mathf.Max(0.05f, importantMoveDuration);
moveDelayBetweenCards = Mathf.Max(0f, moveDelayBetweenCards);
arcHeight = Mathf.Max(0f, arcHeight);
introOverheadHold = Mathf.Max(0f, introOverheadHold);
introCameraMoveDuration = Mathf.Max(0.05f, introCameraMoveDuration);
introMainHold = Mathf.Max(0f, introMainHold);
introDrawDuration = Mathf.Max(0.05f, introDrawDuration);
cameraZoomDuration = Mathf.Max(0.05f, cameraZoomDuration);
vanguardRevealDuration = Mathf.Max(0.05f, vanguardRevealDuration);
triggerRevealDuration = Mathf.Max(0.05f, triggerRevealDuration);
triggerRevealHold = Mathf.Max(0f, triggerRevealHold);
cameraReturnDuration = Mathf.Max(0.05f, cameraReturnDuration);
cameraRezoomDuration = Mathf.Max(0.05f, cameraRezoomDuration);
@ -108,6 +128,10 @@ namespace Vanguard.Battle
intercept = Mathf.Max(0.05f, intercept);
perfectGuard = Mathf.Max(0.05f, perfectGuard);
inputLockTail = Mathf.Max(0f, inputLockTail);
eventPause = Mathf.Max(0f, eventPause);
postDrivePause = Mathf.Max(0f, postDrivePause);
postTriggerPause = Mathf.Max(0f, postTriggerPause);
postDamagePause = Mathf.Max(0f, postDamagePause);
arrowShowDuration = Mathf.Max(0.05f, arrowShowDuration);
arrowHideDuration = Mathf.Max(0.05f, arrowHideDuration);
}

View File

@ -68,6 +68,41 @@ namespace Vanguard.Battle
duration);
}
public void SetImmediate(Vector3 position, Vector3 target, float fieldOfView)
{
if (_camera == null)
return;
_moving = false;
var rotation = Quaternion.LookRotation(target - position, Vector3.up);
Apply(new Pose
{
Position = position,
Rotation = rotation,
FieldOfView = fieldOfView,
});
}
public void MoveTo(
Vector3 position,
Vector3 target,
float fieldOfView,
float duration)
{
if (_camera == null)
return;
var rotation = Quaternion.LookRotation(target - position, Vector3.up);
AnimateTo(
new Pose
{
Position = position,
Rotation = rotation,
FieldOfView = fieldOfView,
},
duration);
}
public void ReturnToOrigin(float duration)
{
if (!_hasOrigin)

View File

@ -1,3 +1,4 @@
using System;
using UnityEngine;
using Vanguard.Battle;
@ -13,6 +14,7 @@ namespace Vanguard.Battle
{
Idle,
Zooming,
Revealing,
Holding,
Returning,
}
@ -22,6 +24,9 @@ namespace Vanguard.Battle
private Stage _stage;
private float _deadline;
private float _zoomDeadline;
private float _revealDeadline;
private float _resolveDeadline;
private bool _chainActive;
private bool _restoreRequested;
private bool _lockHeld;
@ -36,6 +41,18 @@ namespace Vanguard.Battle
public bool IsBusy => _stage != Stage.Idle;
/// <summary>
/// true — камера ушла на trigger-зону, руку нужно скрыть;
/// false — камера вернулась, руку можно показать.
/// </summary>
public event Action<bool> FocusChanged;
/// <summary>Камера закончила зум и стоит на trigger-зоне.</summary>
public event Action ZoomCompleted;
/// <summary>Карта раскрыта и выдержана пауза — можно резолвить триггер.</summary>
public event Action ResolveReady;
public void ShowTrigger(
AnimationConfig config,
Vector3 worldPoint)
@ -43,6 +60,8 @@ namespace Vanguard.Battle
if (_camera == null)
return;
var wasIdle = _stage == Stage.Idle;
if (!_chainActive)
{
_camera.BeginChain();
@ -54,13 +73,21 @@ namespace Vanguard.Battle
var zoom = config != null
? config.cameraZoomDuration
: 0.35f;
var hold = config != null
var reveal = config != null
? config.triggerRevealDuration
: 0.35f;
var pause = config != null
? config.triggerRevealHold
: 1.2f;
: 0.75f;
_camera.ZoomTo(worldPoint, zoom);
_stage = Stage.Zooming;
_deadline = Time.unscaledTime + zoom + hold;
_zoomDeadline = Time.unscaledTime + zoom;
_revealDeadline = _zoomDeadline + reveal;
_resolveDeadline = _revealDeadline + pause;
if (wasIdle)
OnFocusChanged(true);
}
public void ReturnForChoice(AnimationConfig config)
@ -99,15 +126,30 @@ namespace Vanguard.Battle
return;
if (_stage == Stage.Zooming
&& Time.unscaledTime >= _deadline)
&& Time.unscaledTime >= _zoomDeadline)
{
_stage = Stage.Revealing;
ZoomCompleted?.Invoke();
}
if (_stage == Stage.Revealing
&& Time.unscaledTime >= _revealDeadline)
{
_stage = Stage.Holding;
}
if (_stage == Stage.Holding
&& Time.unscaledTime >= _resolveDeadline)
{
ResolveReady?.Invoke();
var duration = config != null
? config.cameraReturnDuration
: 0.4f;
_restoreRequested = true;
_camera.ReturnToOrigin(duration);
_stage = Stage.Returning;
_deadline = Time.unscaledTime + duration;
return;
}
if (_stage == Stage.Returning
@ -121,6 +163,8 @@ namespace Vanguard.Battle
_chainActive = false;
_camera.Restore();
}
OnFocusChanged(false);
}
}
@ -144,10 +188,18 @@ namespace Vanguard.Battle
public void Cancel()
{
var wasIdle = _stage == Stage.Idle;
_stage = Stage.Idle;
_chainActive = false;
Release();
_camera?.Restore();
if (!wasIdle)
OnFocusChanged(false);
}
private void OnFocusChanged(bool focused)
{
FocusChanged?.Invoke(focused);
}
}
}

View File

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Vanguard.Battle;
@ -19,12 +20,17 @@ namespace Vanguard.Board3D
public Vector3 FromScale;
public float Duration;
public float ArcHeight;
public bool Sequential;
public Action<Card3D> OnCompleted;
public Action<Card3D> OnStart;
public bool FromCurrent;
}
private sealed class ActiveMove
{
public Card3D Card;
public float Remaining;
public Action<Card3D> OnCompleted;
}
private readonly Queue<Request> _queue = new Queue<Request>();
@ -86,7 +92,11 @@ namespace Vanguard.Board3D
Quaternion fromRotation,
Vector3 fromScale,
float duration,
float arcHeight)
float arcHeight,
bool sequential = false,
Action<Card3D> onCompleted = null,
Action<Card3D> onStart = null,
bool fromCurrent = false)
{
if (card == null || card.IsDragging)
return;
@ -104,9 +114,47 @@ namespace Vanguard.Board3D
FromScale = fromScale,
Duration = duration,
ArcHeight = arcHeight,
Sequential = sequential,
OnCompleted = onCompleted,
OnStart = onStart,
FromCurrent = fromCurrent,
});
}
/// <summary>
/// Запускает перемещение сразу, без очереди и stagger. Нужен для
/// парных доборов, где две карты должны лететь одновременно.
/// </summary>
public void EnqueueNow(
Card3D card,
Vector3 fromPosition,
Quaternion fromRotation,
Vector3 fromScale,
float duration,
float arcHeight)
{
if (card == null || card.IsDragging)
return;
CancelCard(card);
EnsureLock();
card.StartArcMove(
fromPosition,
fromRotation,
fromScale,
duration,
arcHeight);
if (card.IsMoving)
{
_active.Add(new ActiveMove
{
Card = card,
Remaining = Mathf.Max(0.1f, duration) + 0.05f,
});
}
}
public void Tick(float deltaTime)
{
if (_active.Count > 0)
@ -130,6 +178,7 @@ namespace Vanguard.Board3D
if (!card.IsMoving)
{
_active.RemoveAt(i);
active.OnCompleted?.Invoke(card);
continue;
}
@ -139,6 +188,7 @@ namespace Vanguard.Board3D
{
card.StopMove(snap: true);
_active.RemoveAt(i);
active.OnCompleted?.Invoke(card);
}
}
}
@ -156,6 +206,12 @@ namespace Vanguard.Board3D
public void CancelAll(bool snapCards = false)
{
foreach (var request in _queue)
{
if (request?.Card != null)
request.Card.CancelHold();
}
_queue.Clear();
foreach (var active in _active)
{
@ -173,17 +229,27 @@ namespace Vanguard.Board3D
if (card == null)
return;
var wasQueued = false;
var remaining = new Queue<Request>();
while (_queue.Count > 0)
{
var request = _queue.Dequeue();
if (request.Card != card)
{
remaining.Enqueue(request);
}
else
{
wasQueued = true;
}
}
while (remaining.Count > 0)
_queue.Enqueue(remaining.Dequeue());
if (wasQueued)
card.CancelHold();
for (var i = _active.Count - 1; i >= 0; i--)
{
if (_active[i]?.Card != card)
@ -202,6 +268,14 @@ namespace Vanguard.Board3D
if (_queue.Count == 0)
return;
// Последовательные запросы (добор, возврат в колоду) идут строго
// по одной карте: следующая стартует только после предыдущей.
if (_queue.Peek().Sequential && _active.Count > 0)
{
_delay = 0f;
return;
}
var request = _queue.Dequeue();
var card = request.Card;
if (card == null || card.IsDragging)
@ -210,10 +284,18 @@ namespace Vanguard.Board3D
return;
}
request.OnStart?.Invoke(card);
card.StartArcMove(
request.FromPosition,
request.FromRotation,
request.FromScale,
request.FromCurrent
? card.transform.position
: request.FromPosition,
request.FromCurrent
? card.transform.rotation
: request.FromRotation,
request.FromCurrent
? card.transform.localScale
: request.FromScale,
request.Duration,
request.ArcHeight);
@ -223,8 +305,13 @@ namespace Vanguard.Board3D
{
Card = card,
Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f,
OnCompleted = request.OnCompleted,
});
}
else
{
request.OnCompleted?.Invoke(card);
}
_delay = Mathf.Max(0.01f, _config != null
? _config.moveDelayBetweenCards

View File

@ -30,27 +30,34 @@ MonoBehaviour:
dragThresholdPixels: 7
smoothMoveSpeed: 16
smoothRotateSpeed: 14
handHoverLift: 0.08
handHoverLift: 0.1
handCenterZ: -5.3
handHeight: 1.12
handTilt: 58
handSpacing: 0.42
handSpacing: 0.28
handMaxWidth: 7.4
handArc: 0.035
handViewportX: 0.44
handViewportX: 0.5
handViewportY: 0.1
handViewDistance: 4
handViewMaxWidth: 1.4
handViewMaxWidth: 2.52
handViewScale: 0.5
handViewRoll: 3.2
handViewArc: 0.02
opponentHandEnabled: 1
opponentHandZ: 4.3
opponentHandHeight: 0.75
opponentHandSpacing: 0.42
opponentHandMaxWidth: 2.4
opponentHandScale: 0.8
opponentHandRoll: 2.5
opponentHandSpacing: 0.28
opponentHandMaxWidth: 3.78
opponentHandScale: 0.9
opponentHandRoll: 5
opponentHandArc: 0.08
opponentHandViewportY: 0.76
opponentHandViewDistance: 6
opponentHandViewScale: 0.5
opponentHandViewMaxWidth: 2.4
opponentHandViewRoll: 3.2
opponentHandViewArc: 0.08
guardianRowGap: 0.55
guardianSpacing: 0.62
guardianMaxWidth: 3.6
@ -60,6 +67,9 @@ MonoBehaviour:
cameraPosition: {x: 0, y: 4.2, z: -8}
cameraTarget: {x: 0, y: 0, z: 0}
cameraFov: 46
introCameraPosition: {x: 0, y: 7.5, z: -5.5}
introCameraTarget: {x: 0, y: 0.4, z: 2}
introCameraFov: 55
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}
matOpponentColor: {r: 0.13, g: 0.065, b: 0.075, a: 1}

View File

@ -41,24 +41,25 @@ namespace Vanguard.Board3D
public float dragThresholdPixels = 7f;
public float smoothMoveSpeed = 16f;
public float smoothRotateSpeed = 14f;
public float handHoverLift = 0.08f;
[Tooltip("Подъём карты при наведении, доля её высоты.")]
[Range(0f, 0.5f)] public float handHoverLift = 0.1f;
[Header("Рука на столе (fallback без камеры)")]
public float handCenterZ = -5.3f;
public float handHeight = 1.12f;
[Range(0f, 85f)] public float handTilt = 58f;
public float handSpacing = 0.42f;
public float handSpacing = 0.28f;
public float handMaxWidth = 7.4f;
public float handArc = 0.035f;
[Header("Рука в поле зрения")]
[Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")]
[Range(0.05f, 0.95f)] public float handViewportX = 0.44f;
[Range(0.05f, 0.95f)] public float handViewportX = 0.5f;
[Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")]
[Range(0.05f, 0.45f)] public float handViewportY = 0.10f;
[Tooltip("Расстояние от камеры до веера руки, метры.")]
public float handViewDistance = 4.0f;
public float handViewMaxWidth = 1.4f;
public float handViewMaxWidth = 2.52f;
public float handViewScale = 0.5f;
public float handViewRoll = 3.2f;
public float handViewArc = 0.02f;
@ -67,10 +68,19 @@ namespace Vanguard.Board3D
public bool opponentHandEnabled = true;
public float opponentHandZ = 4.3f;
public float opponentHandHeight = 0.75f;
public float opponentHandSpacing = 0.42f;
public float opponentHandMaxWidth = 2.4f;
public float opponentHandScale = 0.8f;
public float opponentHandRoll = 2.5f;
public float opponentHandSpacing = 0.28f;
public float opponentHandMaxWidth = 3.78f;
public float opponentHandScale = 0.9f;
public float opponentHandRoll = 5f;
public float opponentHandArc = 0.08f;
[Header("Рука противника в поле зрения")]
[Range(0.5f, 0.95f)] public float opponentHandViewportY = 0.76f;
public float opponentHandViewDistance = 6f;
public float opponentHandViewScale = 0.5f;
public float opponentHandViewMaxWidth = 2.4f;
public float opponentHandViewRoll = 3.2f;
public float opponentHandViewArc = 0.08f;
[Header("Круг защиты")]
public float guardianRowGap = 0.55f;
@ -80,6 +90,8 @@ namespace Vanguard.Board3D
[Header("Стопки")]
[Tooltip("Расстояние от центра стола до зоны урона.")]
public float damageX = 3.0f;
[Tooltip("Сдвиг следующей карты урона в долях ширины карты. 0.3 = новая карта перекрывает 30% предыдущей.")]
[Range(0.1f, 0.9f)] public float damageStackOffset = 0.3f;
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
public float pileX = 2.95f;
[Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")]
@ -95,6 +107,12 @@ namespace Vanguard.Board3D
public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
public float cameraFov = 46f;
[Header("Стартовая камера")]
[Tooltip("Начальная позиция: над стороной игрока, лицом к столу.")]
public Vector3 introCameraPosition = new Vector3(0f, 7.5f, -5.5f);
public Vector3 introCameraTarget = new Vector3(0f, 0.4f, 2f);
public float introCameraFov = 55f;
[Header("Палитра")]
public Color tableColor = new Color(0.045f, 0.04f, 0.055f);
public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f);
@ -156,6 +174,8 @@ namespace Vanguard.Board3D
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f);
handViewportX = Mathf.Clamp(handViewportX, 0.05f, 0.95f);
damageStackOffset = Mathf.Clamp(damageStackOffset, 0.1f, 0.9f);
introCameraFov = Mathf.Clamp(introCameraFov, 20f, 90f);
handViewDistance = Mathf.Max(1f, handViewDistance);
handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth);
handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f);
@ -167,6 +187,7 @@ namespace Vanguard.Board3D
opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth);
opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f);
opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f);
opponentHandArc = Mathf.Max(0f, opponentHandArc);
}
}
}

View File

@ -239,16 +239,22 @@ namespace Vanguard.Board3D
/// <summary>Текстура лица и акцентный цвет карты. Front может быть null.</summary>
public sealed class BoardArt
{
public BoardArt(Texture2D front, Color accent, string displayName = null)
public BoardArt(
Texture2D front,
Color accent,
string displayName = null,
string trigger = null)
{
Front = front;
Accent = accent;
DisplayName = displayName;
Trigger = trigger;
}
public Texture2D Front { get; }
public Color Accent { get; }
public string DisplayName { get; }
public string Trigger { get; }
public static BoardArt Fallback(Color accent) => new BoardArt(null, accent);
}

View File

@ -67,9 +67,16 @@ namespace Vanguard.Board3D
var zSign = ZSign(localSeat);
var sideSign = localSeat == 0 ? -1f : 1f;
var x = sideSign * _config.damageX;
// Карты лежат на боку: вертикальный шаг задаётся шириной карты.
// 0.3 — новая карта перекрывает 30% предыдущей.
var step = _config.cardWidth
* _config.cardScale
* _config.damageStackOffset;
var z = zSign * (_config.frontRowZ + 0.4f)
+ zSign * index * 0.4f;
return new Vector3(x, _config.DamageCenterY, z);
+ zSign * index * step;
// Небольшой подъём по Y: новая карта рисуется поверх старой.
var y = _config.DamageCenterY + (index * 0.0015f);
return new Vector3(x, y, z);
}
/// <summary>Колода и дроп сбоку от поля, у своего края.</summary>
@ -152,9 +159,11 @@ namespace Vanguard.Board3D
var offset = index - (safeCount - 1) * 0.5f;
var x = offset * spacing;
var z = _config.opponentHandZ + Mathf.Abs(offset) * 0.015f;
// Как и своя рука: веер по X/Y/roll, а по глубине каждая
// следующая карта ближе к камере и рисуется поверх предыдущей.
var z = _config.opponentHandZ - (index * 0.05f);
var y = _config.opponentHandHeight
- (1f - Mathf.Abs(offset) / safeCount) * 0.02f;
+ (1f - Mathf.Abs(offset) / safeCount) * _config.opponentHandArc;
var roll = -offset * _config.opponentHandRoll;
return new HandPose(new Vector3(x, y, z), roll);
}

View File

@ -45,12 +45,22 @@ namespace Vanguard.Board3D
new Vector3(halfW, 0f, halfH),
new Vector3(-halfW, 0f, halfH),
},
uv = new[]
// Задняя грань смотрит вниз: без зеркального U рубашка
// читается отражённой, когда карта перевёрнута лицом вниз.
uv = front
? new[]
{
new Vector2(0f, 0f),
new Vector2(1f, 0f),
new Vector2(1f, 1f),
new Vector2(0f, 1f),
}
: new[]
{
new Vector2(1f, 0f),
new Vector2(0f, 0f),
new Vector2(0f, 1f),
new Vector2(1f, 1f),
},
normals = new[] { normal, normal, normal, normal },
triangles = new[]

View File

@ -59,6 +59,36 @@ namespace Vanguard.Board3D
view._renderer = renderer;
view.ApplyColor(false);
// Тёмная пластина под рамкой: слот читается как площадка, а не
// как пустой контур. Цвет — синий для R, тёплый для V.
var plate = GameObject.CreatePrimitive(PrimitiveType.Cube);
plate.name = "Slot Plate";
var plateCollider = plate.GetComponent<Collider>();
if (plateCollider != null)
{
if (Application.isPlaying)
Object.Destroy(plateCollider);
else
Object.DestroyImmediate(plateCollider);
}
plate.transform.SetParent(go.transform, false);
plate.transform.localPosition = new Vector3(0f, -0.003f, 0f);
plate.transform.localRotation = Quaternion.identity;
plate.transform.localScale = new Vector3(
config.cardWidth * 1.24f,
0.004f,
config.cardHeight * 1.24f);
var plateRenderer = plate.GetComponent<MeshRenderer>();
plateRenderer.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(plateRenderer, false);
var plateBlock = new MaterialPropertyBlock();
var plateColor = slot == PlaymatLayout.VanguardSlot
? new Color(0.10f, 0.07f, 0.045f, 1f)
: new Color(0.035f, 0.07f, 0.13f, 1f);
BoardMaterials.SetColor(plateBlock, plateRenderer.sharedMaterial, plateColor);
plateRenderer.SetPropertyBlock(plateBlock);
var collider = go.AddComponent<BoxCollider>();
collider.size = new Vector3(
config.cardWidth * 1.12f,

View File

@ -13,6 +13,7 @@ namespace Vanguard.Board3D
private MeshRenderer _front;
private MeshRenderer _back;
private MeshRenderer _edge;
private MeshRenderer _frame;
private MeshRenderer _highlight;
private BoxCollider _collider;
private MaterialPropertyBlock _block;
@ -35,6 +36,20 @@ namespace Vanguard.Board3D
private float _arcElapsed;
private float _arcHeight;
private bool _revealActive;
private float _revealElapsed;
private float _revealDuration;
private int _revealDirection;
private Vector3 _revealFromPosition;
private Vector3 _revealFromScale;
// Пока запрос ждёт в очереди CardMoveAnimator, карта не должна
// плавно двигаться к target — иначе она окажется у цели до дуги.
private bool _hold;
private Vector3 _holdPosition;
private Quaternion _holdRotation;
private Vector3 _holdScale;
private bool _dragging;
private Vector3 _dragOriginPosition;
private Quaternion _dragOriginRotation;
@ -48,9 +63,20 @@ namespace Vanguard.Board3D
public Vector3 TargetPosition => _targetPosition;
public float TargetYaw => _targetRotation.eulerAngles.y;
public Quaternion TargetRotation => _targetRotation;
/// <summary>
/// Переворот рубашкой вверх с сохранением вертикальной ориентации
/// рисунка рубашки.
/// </summary>
public static Quaternion FaceDownFlip =>
Quaternion.Euler(0f, 180f, 0f) * Quaternion.Euler(180f, 0f, 0f);
/// <summary>Поза рубашкой вверх для текущей базовой ориентации.</summary>
public Quaternion FaceDownRotation => _baseRotation * FaceDownFlip;
public bool IsAnimating =>
!_dragging
&& (_arcMoving
|| _revealActive
|| Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
@ -97,6 +123,21 @@ namespace Vanguard.Board3D
_front.sharedMaterial = BoardMaterials.White;
BoardMaterials.SetShadows(_front, false);
var frameGo = new GameObject("Frame");
frameGo.transform.SetParent(transform, false);
frameGo.transform.localPosition = new Vector3(
0f,
(thickness * 0.5f) + 0.0012f,
0f);
var frameFilter = frameGo.AddComponent<MeshFilter>();
frameFilter.sharedMesh = BoardMeshLibrary.Frame(
width,
height,
Mathf.Max(0.006f, width * 0.009f));
_frame = frameGo.AddComponent<MeshRenderer>();
_frame.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(_frame, false);
var backGo = new GameObject("Back");
backGo.transform.SetParent(transform, false);
backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f);
@ -108,12 +149,15 @@ namespace Vanguard.Board3D
var highlightGo = new GameObject("Highlight");
highlightGo.transform.SetParent(transform, false);
highlightGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0015f, 0f);
highlightGo.transform.localPosition = new Vector3(
0f,
(thickness * 0.5f) + 0.0018f,
0f);
var filter = highlightGo.AddComponent<MeshFilter>();
filter.sharedMesh = BoardMeshLibrary.Frame(
width * 1.16f,
height * 1.16f,
Mathf.Max(0.025f, width * 0.035f));
width,
height,
Mathf.Max(0.006f, width * 0.009f));
_highlight = highlightGo.AddComponent<MeshRenderer>();
_highlight.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(_highlight, false);
@ -122,7 +166,7 @@ namespace Vanguard.Board3D
_stats = BoardText.Create(
transform,
string.Empty,
0.028f,
0.034f,
Color.white,
Vector3.zero);
_stats.gameObject.SetActive(false);
@ -140,7 +184,9 @@ namespace Vanguard.Board3D
name = "Card3D " + reference;
}
public void SetArt(Texture2D texture, Color accent)
public void SetArt(
Texture2D texture,
Color accent)
{
if (_front == null)
return;
@ -156,6 +202,19 @@ namespace Vanguard.Board3D
_front.sharedMaterial,
texture != null ? Color.white : accent);
_front.SetPropertyBlock(_block);
if (_frame != null)
{
_frame.GetPropertyBlock(_block);
_block.Clear();
BoardMaterials.SetColor(
_block,
_frame.sharedMaterial,
new Color(0.015f, 0.015f, 0.018f, 1f));
_frame.SetPropertyBlock(_block);
_frame.gameObject.SetActive(Ref.Zone != BoardZone.Hand);
}
}
public void SetAnimationSpeeds(float moveSpeed, float rotateSpeed)
@ -190,7 +249,7 @@ namespace Vanguard.Board3D
_stats.transform.position = new Vector3(
position.x,
position.y + 0.015f,
position.z - (_config.cardHeight * 0.62f));
position.z - (_config.cardHeight * 0.68f));
_stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
}
@ -207,7 +266,13 @@ namespace Vanguard.Board3D
// Атака/Rest — по часовой стрелке (+90°), урон лежит против (-90°).
var restYaw = rested ? (damageLayout ? -90f : 90f) : 0f;
var rest = Quaternion.Euler(0f, restYaw, 0f);
var flip = Quaternion.Euler(faceUp ? 0f : 180f, 0f, 0f);
// Урон переворачивается вокруг длинной стороны карты, а не
// поперёк неё. Обычная рубашка сохраняет вертикальную ориентацию.
var flip = faceUp
? Quaternion.identity
: damageLayout
? Quaternion.Euler(0f, 0f, 180f)
: FaceDownFlip;
_targetRotation = rest * baseRotation * flip;
}
@ -240,11 +305,60 @@ namespace Vanguard.Board3D
{
_arcMoving = false;
_immediate = false;
_hold = false;
_revealActive = false;
transform.position = position;
transform.rotation = rotation;
transform.localScale = scale;
}
public void HoldAtSource(
Vector3 position,
Quaternion rotation,
Vector3 scale)
{
_hold = true;
_holdPosition = position;
_holdRotation = rotation;
_holdScale = scale;
_immediate = false;
_revealActive = false;
transform.position = position;
transform.rotation = rotation;
transform.localScale = scale;
}
public void CancelHold()
{
_hold = false;
}
public void Reveal(float duration, int direction = 1)
{
if (duration <= 0.001f)
{
Snap();
return;
}
_dragging = false;
_arcMoving = false;
_hold = false;
_immediate = false;
_revealActive = true;
_revealElapsed = 0f;
_revealDuration = Mathf.Max(0.01f, duration);
_revealDirection = direction < 0 ? -1 : 1;
_revealFromPosition = _targetPosition + (Vector3.up * 0.12f);
_revealFromScale = _targetScale * 0.85f;
// Начинаем с текущей позы рубашкой вверх и переворачиваем
// вокруг длинной оси карты: слева направо, у противника зеркально.
transform.position = _revealFromPosition;
transform.rotation = _baseRotation * FaceDownFlip;
transform.localScale = _revealFromScale;
}
public void StartArcMove(
Vector3 fromPosition,
Quaternion fromRotation,
@ -257,6 +371,8 @@ namespace Vanguard.Board3D
_arcMoving = false;
_immediate = false;
_hold = false;
_revealActive = false;
var distance = Vector3.Distance(fromPosition, _targetPosition);
var angle = Quaternion.Angle(fromRotation, _targetRotation);
@ -283,10 +399,12 @@ namespace Vanguard.Board3D
public void StopMove(bool snap)
{
if (!_arcMoving)
if (!_arcMoving && !_hold && !_revealActive)
return;
_arcMoving = false;
_hold = false;
_revealActive = false;
if (snap)
Snap();
}
@ -318,6 +436,8 @@ namespace Vanguard.Board3D
return;
_dragging = true;
_hold = false;
_revealActive = false;
_dragOriginPosition = transform.position;
_dragOriginRotation = transform.rotation;
_targetScale = _baseScale * 1.05f;
@ -326,23 +446,53 @@ namespace Vanguard.Board3D
SetHighlight(null);
}
public float DragLift => Ref.Zone == BoardZone.Hand
? _config.cardHeight * _baseScale.x * _config.handHoverLift
: _config.dragLift;
public void DragTo(Vector3 worldPosition)
{
if (!_dragging)
return;
var yaw = _targetRotation.eulerAngles.y;
// Сохраняем исходную ориентацию карты: карта из руки остаётся
// лицом к игроку, а не «ложится» на стол при перетаскивании.
// Высота та же, что у hover: 10% высоты карты.
transform.position = new Vector3(
worldPosition.x,
_config.dragLift + (_config.cardThickness * 0.5f),
DragLift + (_config.cardThickness * 0.5f),
worldPosition.z);
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
transform.rotation = _dragOriginRotation;
transform.localScale = _baseScale * 1.05f;
}
/// <summary>
/// Отпустить карту в текущей точке drag-а, не возвращая её в руку.
/// Держим её поднятой до ответа сервера или явного возврата.
/// </summary>
public void EndDragAtCurrent()
{
_dragging = false;
if (_collider != null)
_collider.enabled = true;
_hold = true;
_holdPosition = transform.position;
_holdRotation = transform.rotation;
_holdScale = transform.localScale;
_targetPosition = _holdPosition;
_targetRotation = _holdRotation;
_targetScale = _holdScale;
_arcMoving = false;
_revealActive = false;
_immediate = false;
}
public void DropAt(Vector3 position, Quaternion rotation)
{
_dragging = false;
_hold = false;
_revealActive = false;
if (_collider != null)
_collider.enabled = true;
_targetPosition = position;
@ -354,6 +504,8 @@ namespace Vanguard.Board3D
public void ReturnToOrigin()
{
_dragging = false;
_hold = false;
_revealActive = false;
if (_collider != null)
_collider.enabled = true;
_targetPosition = _dragOriginPosition;
@ -365,6 +517,8 @@ namespace Vanguard.Board3D
public void Snap()
{
_arcMoving = false;
_hold = false;
_revealActive = false;
transform.position = _targetPosition;
transform.rotation = _targetRotation;
transform.localScale = _targetScale;
@ -376,6 +530,36 @@ namespace Vanguard.Board3D
if (_dragging || deltaTime <= 0f)
return;
if (_revealActive)
{
_revealElapsed += deltaTime;
var t = Mathf.Clamp01(_revealElapsed / _revealDuration);
var eased = t * t * (3f - (2f * t));
var angle = 180f * eased * _revealDirection;
transform.position = Vector3.Lerp(
_revealFromPosition,
_targetPosition,
eased);
transform.rotation = _baseRotation
* Quaternion.Euler(0f, 0f, angle)
* FaceDownFlip;
transform.localScale = Vector3.Lerp(
_revealFromScale,
_targetScale,
eased);
if (t >= 1f)
{
_revealActive = false;
Snap();
}
return;
}
if (_hold && !_arcMoving)
return;
if (_arcMoving)
{
_arcElapsed += deltaTime;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Vanguard.Board3D;
using Vanguard.Shared.Protocol;
namespace Vanguard.UI.Overlays
{
/// <summary>
/// Компактная панель игрока: Колода, Душа, Рука, Сброс.
/// Все блоки одинаковые; кликабельны только Душа и Сброс.
/// </summary>
public sealed class PlayerStatsPanelView
{
private sealed class StatEntry
{
public BoardZone Zone;
public Text Value;
}
private readonly List<StatEntry> _stats = new List<StatEntry>();
private RectTransform _root;
private Text _header;
public RectTransform Root => _root;
public event Action<BoardZone> ZoneClicked;
public void Build(
Transform parent,
string name,
bool self,
Vector2 anchor,
Vector2 pivot,
Vector2 position,
Vector2 size)
{
_root = UiKit.CreatePanel(
parent,
name,
new Color(0.02f, 0.035f, 0.055f, 0.9f));
_root.anchorMin = anchor;
_root.anchorMax = anchor;
_root.pivot = pivot;
_root.anchoredPosition = position;
_root.sizeDelta = size;
var image = _root.GetComponent<Image>();
if (image != null)
image.raycastTarget = false;
UiKit.AddVerticalLayout(_root, 8, 4);
_header = UiKit.CreateText(
_root,
"Header",
self ? "ВЫ" : "ПРОТИВНИК",
13,
new Color(0.86f, 0.91f, 1f),
TextAnchor.MiddleLeft);
_header.fontStyle = FontStyle.Bold;
UiKit.MakeLiteral(_header);
UiKit.SetPreferredHeight(_header, 20f);
var stats = UiKit.CreatePanel(_root, "Stats", Color.clear);
UiKit.AddHorizontalLayout(stats, 0, 4);
UiKit.SetPreferredHeight(stats, 42f);
AddStat(stats, BoardZone.Deck, "Колода");
AddStat(stats, BoardZone.Soul, "Душа", clickable: true);
AddStat(stats, BoardZone.Hand, "Рука");
AddStat(stats, BoardZone.Drop, "Сброс", clickable: true);
}
private void AddStat(
RectTransform parent,
BoardZone zone,
string label,
bool clickable = false)
{
var block = UiKit.CreatePanel(
parent,
"Stat_" + zone,
new Color(0.06f, 0.09f, 0.14f, 0.85f));
var image = block.GetComponent<Image>();
if (image != null)
image.raycastTarget = clickable;
UiKit.AddVerticalLayout(block, 2, 0);
UiKit.SetPreferredHeight(block, 40f);
var caption = UiKit.CreateText(
block,
"Caption",
label,
9,
new Color(0.62f, 0.70f, 0.86f));
UiKit.MakeLiteral(caption);
UiKit.SetPreferredHeight(caption, 12f);
var value = UiKit.CreateText(
block,
"Value",
"0",
15,
Color.white);
value.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(value, 18f);
if (clickable)
{
var button = block.gameObject.AddComponent<Button>();
button.targetGraphic = image;
button.onClick.AddListener(() => ZoneClicked?.Invoke(zone));
}
_stats.Add(new StatEntry { Zone = zone, Value = value });
}
public void Update(PlayerView player, bool self)
{
if (_root == null)
return;
_header.text = (self ? "ВЫ" : "ПРОТИВНИК")
+ (player != null && !string.IsNullOrEmpty(player.Name)
? " · " + player.Name
: string.Empty);
var hand = player != null
? Mathf.Max(player.HandCount, player.Hand?.Count ?? 0)
: 0;
Set(BoardZone.Deck, player?.DeckCount ?? 0);
Set(BoardZone.Soul, player?.Soul?.Count ?? 0);
Set(BoardZone.Hand, hand);
Set(BoardZone.Drop, player?.Drop?.Count ?? 0);
}
private void Set(BoardZone zone, int value)
{
foreach (var stat in _stats)
{
if (stat.Zone == zone && stat.Value != null)
{
stat.Value.text = value.ToString();
return;
}
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1b9c336ae1690a44dbdc24ee68be4d4d

View File

@ -162,32 +162,24 @@ namespace Vanguard.Tests
.First(go => go.name == "BattleScreen");
screen.SetActive(true);
var self = ReferenceRect(FindRect("ZoneBlocksSelf"));
var opponent = ReferenceRect(FindRect("ZoneBlocksOpponent"));
var phase = ReferenceRect(FindRect("PhasePanel"));
var self = ReferenceRect(FindRect("PlayerStatsSelf"));
var opponent = ReferenceRect(FindRect("PlayerStatsOpponent"));
var stats = ReferenceRect(FindRect("CombatStats"));
AssertWithinScreen(self, "self zones");
AssertWithinScreen(opponent, "opponent zones");
AssertWithinScreen(phase, "phase panel");
AssertWithinScreen(self, "self stats");
AssertWithinScreen(opponent, "opponent stats");
AssertWithinScreen(stats, "combat stats");
Assert.IsFalse(self.Overlaps(opponent));
Assert.IsFalse(self.Overlaps(phase));
Assert.IsFalse(opponent.Overlaps(phase));
Assert.IsFalse(opponent.Overlaps(stats));
Assert.IsFalse(phase.Overlaps(stats));
var toggle = Resources.FindObjectsOfTypeAll<Button>()
.First(button => button.name == "ToggleIntentPanel"
&& button.gameObject.scene.IsValid());
toggle.onClick.Invoke();
var movedOpponent = ReferenceRect(FindRect("ZoneBlocksOpponent"));
var movedPhase = ReferenceRect(FindRect("PhasePanel"));
Assert.Greater(movedOpponent.xMin, opponent.xMin);
Assert.Greater(movedPhase.xMin, phase.xMin);
AssertWithinScreen(movedOpponent, "opponent zones after hide");
AssertWithinScreen(movedPhase, "phase panel after hide");
var debug = FindRect("DebugPanel");
Assert.IsTrue(debug.gameObject.activeSelf, "Debug panel should open by toggle");
AssertWithinScreen(ReferenceRect(debug), "debug panel");
}
finally
{
@ -228,28 +220,28 @@ namespace Vanguard.Tests
var usedField = typeof(BattleView)
.GetField("_mulliganUsed", BindingFlags.Instance | BindingFlags.NonPublic)!;
var rebuild = typeof(BattleView)
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!;
.GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
viewField.SetValue(battle, match);
usedField.SetValue(battle, true);
rebuild.Invoke(battle, new object[] { match });
Assert.IsFalse(
FindRect("PhasePanel").gameObject.activeSelf,
"Phase panel must disappear after the mulligan exchange");
FindRect("ConfirmationBar").gameObject.activeSelf,
"Confirmation bar must disappear after the mulligan exchange");
Assert.IsFalse(
Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Phase_FinishMulliganIntent"),
.Any(go => go.name == "Intent_FinishMulliganIntent"),
"Finish mulligan button must not remain after the exchange");
usedField.SetValue(battle, false);
rebuild.Invoke(battle, new object[] { match });
Assert.IsTrue(
FindRect("PhasePanel").gameObject.activeSelf,
FindRect("ConfirmationBar").gameObject.activeSelf,
"Finish mulligan button must be available before the exchange");
Assert.IsTrue(
Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Phase_FinishMulliganIntent"));
.Any(go => go.name == "Intent_FinishMulliganIntent"));
}
finally
{
@ -259,7 +251,7 @@ namespace Vanguard.Tests
}
[Test]
public void Guard_Control_Is_One_Button_In_Action_Panel()
public void Guard_Control_Is_One_Button_In_Confirmation_Bar()
{
var config = new AppConfig();
config.Load();
@ -290,13 +282,13 @@ namespace Vanguard.Tests
var viewField = typeof(BattleView)
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!;
var rebuild = typeof(BattleView)
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!;
.GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
viewField.SetValue(battle, match);
rebuild.Invoke(battle, new object[] { match });
Assert.IsTrue(
FindRect("PhasePanel").gameObject.activeSelf,
"Guard controls must be in the action panel");
FindRect("ConfirmationBar").gameObject.activeSelf,
"Guard controls must be in the confirmation bar");
var guardButton = Resources.FindObjectsOfTypeAll<Button>()
.FirstOrDefault(button => button.name == "Phase_Guard");
Assert.IsNotNull(guardButton);
@ -311,10 +303,6 @@ namespace Vanguard.Tests
Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Phase_PassGuardIntent"),
"Pass guard must be the same single button");
Assert.AreEqual(
"ДЕЙСТВИЯ",
Resources.FindObjectsOfTypeAll<Text>()
.First(text => text.name == "PhaseTitle").text);
}
finally
{
@ -762,17 +750,17 @@ namespace Vanguard.Tests
&& card.Ref.InstanceId == 7));
var selfZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "ZoneBlocksSelf");
.FirstOrDefault(go => go.name == "PlayerStatsSelf");
var opponentZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "ZoneBlocksOpponent");
.FirstOrDefault(go => go.name == "PlayerStatsOpponent");
Assert.IsNotNull(selfZoneRoot);
Assert.IsNotNull(opponentZoneRoot);
var selfLabels = selfZoneRoot.GetComponentsInChildren<Text>();
var opponentLabels = opponentZoneRoot.GetComponentsInChildren<Text>();
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Рука 1")));
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Душа 1")));
Assert.IsTrue(opponentLabels.Any(text => text.text.Contains("Рука 0")));
Assert.IsTrue(selfLabels.Any(text => text.text == "Рука"));
Assert.IsTrue(selfLabels.Any(text => text.text.StartsWith("Душа")));
Assert.IsTrue(opponentLabels.Any(text => text.text == "Рука"));
Cleanup();
}

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using Vanguard.Battle;
@ -75,7 +76,7 @@ namespace Vanguard.Tests
&& card.Ref.Slot == 3);
Assert.IsTrue(rested.IsRested);
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f);
Assert.AreEqual("P8000 C1", rested.StatsText);
Assert.AreEqual("\u25B28000", rested.StatsText);
var hiddenDamage = cards.Single(card =>
card.Ref.Zone == BoardZone.Damage
@ -186,7 +187,611 @@ namespace Vanguard.Tests
}
[Test]
public void Trigger_Zone_Uses_Local_Plus_And_Minus_Ninety_Degrees()
public void Hands_Fill_Mirrored_And_Newest_Is_On_Top()
{
var config = PlaymatLayoutConfig.CreateDefault();
var cameraGo = new GameObject("Main Camera", typeof(Camera))
{
tag = "MainCamera",
};
var camera = cameraGo.GetComponent<Camera>();
camera.fieldOfView = config.cameraFov;
camera.transform.position = config.cameraPosition;
camera.transform.rotation = Quaternion.LookRotation(
config.cameraTarget - config.cameraPosition,
Vector3.up);
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var player = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0)
.OrderBy(card => card.Ref.Index)
.ToList();
var opponent = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 1)
.OrderBy(card => card.Ref.Index)
.ToList();
Assert.AreEqual(2, player.Count);
Assert.AreEqual(3, opponent.Count);
var playerOldest = camera.WorldToViewportPoint(
player[0].transform.position);
var playerNewest = camera.WorldToViewportPoint(
player[1].transform.position);
Assert.Less(
playerOldest.x,
playerNewest.x,
"Player hand fills left-to-right: the newest card is on the right");
Assert.Less(
playerNewest.z,
playerOldest.z,
"Player newest card must be closer to the camera");
var opponentOldest = camera.WorldToViewportPoint(
opponent[0].transform.position);
var opponentNewest = camera.WorldToViewportPoint(
opponent[2].transform.position);
Assert.Greater(
opponentOldest.x,
opponentNewest.x,
"Opponent hand fills right-to-left: the newest card is on the left");
Assert.Less(
opponentNewest.z,
opponentOldest.z,
"Opponent newest card must be closer to the camera");
var view = BuildView(you: 0);
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
view.Players[1].HandCount = 4;
board.Render(view, new BoardRenderState(), animateMoves: false);
var newPlayer = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 2);
var newOpponent = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 1
&& card.Ref.Index == 3);
Assert.Greater(
camera.WorldToViewportPoint(newPlayer.TargetPosition).x,
playerNewest.x,
"A newly drawn player card must appear to the right");
Assert.Less(
camera.WorldToViewportPoint(newOpponent.TargetPosition).x,
opponentNewest.x,
"A newly drawn opponent card must appear to the left");
var drawSource = board.Layout.DeckPosition(0)
+ (Vector3.up * 0.03f);
Assert.Less(
Vector3.Distance(newPlayer.transform.position, drawSource),
0.01f,
"A drawn card must start above the deck");
Assert.AreEqual(
config.cardScale,
newPlayer.transform.localScale.x,
0.01f,
"A drawn card must start at deck scale, not hand scale");
}
finally
{
Object.DestroyImmediate(board.gameObject);
Object.DestroyImmediate(cameraGo);
}
}
[Test]
public void Hand_Reflows_While_A_Card_Leaves_From_Hand()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
var leaving = new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
};
view.Players[0].Hand.Add(leaving);
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldestBefore = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
var next = BuildView(you: 0);
next.Players[0].RearGuards[3] = leaving;
board.Render(next, new BoardRenderState());
var oldestAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestBefore, oldestAfter),
0.01f,
"The hand must start reflowing when a card leaves");
Assert.GreaterOrEqual(
board.MoveAnimator.PendingCount,
1,
"The leaving card must still be queued for movement");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Dropped_Hand_Card_Holds_At_Drop_Until_Returned()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var card = ActiveCards().Single(item =>
item.Ref.Zone == BoardZone.Hand
&& item.Ref.Player == 0
&& item.Ref.Index == 0);
var handTarget = card.TargetPosition;
card.BeginDrag();
card.DragTo(new Vector3(1f, 0f, 1f));
var held = card.transform.position;
card.EndDragAtCurrent();
Assert.IsFalse(card.IsDragging);
Assert.Less(
Vector3.Distance(card.TargetPosition, held),
0.001f,
"Dropped card must stay at the drop point");
card.Tick(0.2f);
Assert.Less(
Vector3.Distance(card.transform.position, held),
0.001f,
"Dropped card must not twitch while waiting for a snapshot");
var pendingField = typeof(GameBoard3D).GetField(
"_dropPendingCard",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.IsNotNull(pendingField);
pendingField.SetValue(board, card);
board.ReturnDroppedCard();
Assert.Less(
Vector3.Distance(card.TargetPosition, handTarget),
0.001f,
"Returned card must target its hand pose again");
card.Tick(0.5f);
Assert.Less(
Vector3.Distance(card.transform.position, handTarget),
0.05f);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Hand_Hover_Lifts_Card_By_Ten_Percent()
{
var config = PlaymatLayoutConfig.CreateDefault();
var cameraGo = new GameObject("Main Camera", typeof(Camera))
{
tag = "MainCamera",
};
var camera = cameraGo.GetComponent<Camera>();
camera.fieldOfView = config.cameraFov;
camera.transform.position = config.cameraPosition;
camera.transform.rotation = Quaternion.LookRotation(
config.cameraTarget - config.cameraPosition,
Vector3.up);
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var handCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0);
var before = handCard.TargetPosition;
var hoverField = typeof(GameBoard3D).GetField(
"_hoveredHandCard",
BindingFlags.Instance | BindingFlags.NonPublic);
var updateHand = typeof(GameBoard3D).GetMethod(
"UpdateHandView",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.IsNotNull(hoverField);
Assert.IsNotNull(updateHand);
hoverField.SetValue(board, handCard);
updateHand.Invoke(board, null);
var lifted = handCard.TargetPosition;
var delta = lifted - before;
Assert.Greater(delta.magnitude, 0.01f);
Assert.Greater(
Vector3.Dot(delta.normalized, camera.transform.up),
0.9f,
"Hover lift must go up in camera space");
hoverField.SetValue(board, null);
updateHand.Invoke(board, null);
Assert.Less(
Vector3.Distance(handCard.TargetPosition, before),
0.001f,
"Unhover must return the card to its hand pose");
}
finally
{
Object.DestroyImmediate(board.gameObject);
Object.DestroyImmediate(cameraGo);
}
}
[Test]
public void Hand_Draw_Stages_New_Cards_One_By_One()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldestTargetBefore = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
var next = BuildView(you: 0);
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/009EN",
InstanceId = 14,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.Render(next, new BoardRenderState());
Assert.IsTrue(board.StagedDrawHandActive);
Assert.AreEqual(2, board.StagedDrawCards.Count);
Assert.AreEqual(2, board.MoveAnimator.PendingCount);
Assert.AreEqual(2, ActiveCards().Count(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0));
// Повторный snapshot без новых доборов не должен сбрасывать
// уже начатую ступенчатую раздачу.
board.Render(next, new BoardRenderState());
Assert.IsTrue(board.StagedDrawHandActive);
Assert.AreEqual(2, board.StagedDrawCards.Count);
var oldestTargetAtDrawStart = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Less(
Vector3.Distance(
oldestTargetBefore,
oldestTargetAtDrawStart),
0.001f,
"The hand must not reflow before the first draw arrives");
board.MoveAnimator.Tick(0f);
Assert.AreEqual(1, board.MoveAnimator.ActiveCount);
var firstDrawn = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 2);
firstDrawn.Tick(0.7f);
board.MoveAnimator.Tick(0f);
Assert.AreEqual(3, board.StagedDrawCards.Count);
Assert.IsTrue(board.StagedDrawHandActive);
var oldestTargetAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestTargetBefore, oldestTargetAfter),
0.01f,
"The hand must reflow after the first drawn card arrives");
Assert.AreEqual(3, ActiveCards().Count(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0));
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Mulligan_Remaining_Cards_Reflow_After_Each_Return()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/009EN",
InstanceId = 14,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldestTargetBefore = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
var next = BuildView(you: 0);
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/010EN",
InstanceId = 15,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
next.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/011EN",
InstanceId = 16,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.QueueHandReturnToDeck(0, 2);
board.Render(next, new BoardRenderState());
Assert.IsTrue(board.StagedReturnHandActive);
Assert.AreEqual(4, board.StagedReturnCards.Count);
Assert.IsTrue(board.StagedDrawHandActive);
// Первый возврат.
board.MoveAnimator.Tick(0f);
board.ReturningCards[0].Tick(0.7f);
board.MoveAnimator.Tick(0f);
// Второй возврат.
board.MoveAnimator.Tick(0.3f);
board.ReturningCards[0].Tick(0.7f);
board.MoveAnimator.Tick(0f);
Assert.IsFalse(board.StagedReturnHandActive);
Assert.AreEqual(2, board.StagedReturnCards.Count);
Assert.IsTrue(board.StagedDrawHandActive);
var oldestTargetAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestTargetBefore, oldestTargetAfter),
0.01f,
"Remaining mulligan cards must reflow after returns");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Hand_Return_To_Deck_Starts_Right_To_Left_And_Targets_Deck_Bottom()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
var view = BuildView(you: 0);
view.Players[0].Hand.Add(new CardView
{
PrintingId = "BT01/008EN",
InstanceId = 13,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
});
board.SetVisible(true);
board.Render(view, new BoardRenderState());
var oldHand = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0)
.ToDictionary(card => card.Ref.Index);
var source13 = oldHand[2].transform.position;
var source12 = oldHand[1].transform.position;
var oldestTargetBefore = oldHand[0].TargetPosition;
var next = BuildView(you: 0);
next.Players[0].Hand = new List<CardView>
{
next.Players[0].Hand[0],
};
board.QueueHandReturnToDeck(0, 2);
board.Render(next, new BoardRenderState());
Assert.AreEqual(2, board.ReturningCardCount);
var returning = board.ReturningCards;
Assert.AreEqual(2, returning[0].Ref.Index);
Assert.AreEqual(1, returning[1].Ref.Index);
Assert.Less(
Vector3.Distance(returning[0].transform.position, source13),
0.001f,
"The rightmost return card must wait in the hand until its turn");
Assert.Less(
Vector3.Distance(returning[1].transform.position, source12),
0.001f,
"The next return card must stay in the hand until the first finishes");
Assert.IsTrue(board.StagedReturnHandActive);
Assert.AreEqual(3, board.StagedReturnCards.Count);
var oldestAfter = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Less(
Vector3.Distance(oldestTargetBefore, oldestAfter),
0.001f,
"The hand must not reflow before the first card leaves");
var deckBottom = board.Layout.DeckPosition(0)
- (Vector3.up * 0.04f);
Assert.IsTrue(board.TryGetHandReturnTarget(
returning[0],
out var returnTarget,
out var returnRotation));
Assert.Less(
Vector3.Distance(returnTarget, deckBottom),
0.001f);
Assert.Greater(
Vector3.Dot(returnRotation * Vector3.up, Vector3.down),
0.9f);
Assert.AreEqual(2, board.MoveAnimator.PendingCount);
board.MoveAnimator.Tick(0f);
Assert.IsTrue(returning[0].IsMoving);
returning[0].Tick(0.7f);
board.MoveAnimator.Tick(0f);
Assert.AreEqual(1, board.ReturningCardCount);
Assert.AreEqual(2, board.StagedReturnCards.Count);
var oldestAfterFirst = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0
&& card.Ref.Index == 0).TargetPosition;
Assert.Greater(
Vector3.Distance(oldestTargetBefore, oldestAfterFirst),
0.01f,
"The hand must reflow after the first card leaves");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void CardMoveAnimator_Runs_Sequential_Requests_And_Completes_Each()
{
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);
cardB.PlaceImmediate(from, Quaternion.identity, Vector3.one);
cardB.SetPose(to, Quaternion.identity, true, false, false);
var completed = new List<Card3D>();
animator.Enqueue(
cardA, from, Quaternion.identity, Vector3.one,
0.1f, 0.2f, sequential: true,
onCompleted: card => completed.Add(card));
animator.Enqueue(
cardB, from, Quaternion.identity, Vector3.one,
0.1f, 0.2f, sequential: true,
onCompleted: card => completed.Add(card));
animator.Tick(0f);
Assert.AreEqual(1, animator.ActiveCount);
Assert.AreEqual(1, animator.PendingCount);
cardA.Tick(0.2f);
animator.Tick(0f);
CollectionAssert.AreEqual(new[] { cardA }, completed);
Assert.AreEqual(0, animator.ActiveCount);
Assert.AreEqual(1, animator.PendingCount);
animator.Tick(0.3f);
Assert.AreEqual(1, animator.ActiveCount);
cardB.Tick(0.2f);
animator.Tick(0f);
CollectionAssert.AreEqual(new[] { cardA, cardB }, completed);
Assert.IsFalse(animator.IsBusy);
}
finally
{
animator.CancelAll();
Object.DestroyImmediate(root);
}
}
[Test]
public void Trigger_Zone_Uses_Local_Minus_And_Plus_Ninety_Degrees()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
@ -226,11 +831,11 @@ namespace Vanguard.Tests
var layout = new PlaymatLayout(config);
Assert.AreEqual(
90f,
-90f,
Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw),
0.1f);
Assert.AreEqual(
-90f,
90f,
Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw),
0.1f);
}
@ -570,6 +1175,161 @@ namespace Vanguard.Tests
}
}
[Test]
public void Removing_Card_From_Hand_Does_Not_Queue_Arcs_For_Remaining_Hand()
{
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 called = self.Hand.First(card => card.InstanceId == 11);
self.Hand.Remove(called);
self.RearGuards[0] = called;
board.Render(view, new BoardRenderState());
var remaining = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == view.You
&& card.Ref.InstanceId == 12);
Assert.IsFalse(
board.MoveAnimator.IsCardQueued(remaining),
"Remaining hand cards must reflow without arc requests");
Assert.IsFalse(
board.MoveAnimator.IsCardActive(remaining),
"Remaining hand cards must reflow without active arc requests");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Queued_Card_Holds_At_Source_Until_Arc_Starts()
{
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 first = self.Hand[0];
var second = self.Hand[1];
self.Hand.RemoveAt(1);
self.Hand.RemoveAt(0);
self.RearGuards[0] = first;
self.RearGuards[1] = second;
board.Render(view, new BoardRenderState());
board.MoveAnimator.Tick(0.001f);
var firstCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == view.You
&& card.Ref.Slot == 0);
var secondCard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == view.You
&& card.Ref.Slot == 1);
Assert.IsTrue(firstCard.IsMoving || board.MoveAnimator.IsCardActive(firstCard),
"First card should already be moving");
Assert.IsTrue(board.MoveAnimator.IsCardQueued(secondCard),
"Second card should be queued behind the first");
Assert.Greater(
Vector3.Distance(secondCard.transform.position, secondCard.TargetPosition),
0.5f,
"Queued card must stay at source, not smooth-move toward target");
board.MoveAnimator.Tick(10f);
Assert.IsTrue(secondCard.IsMoving || board.MoveAnimator.IsCardActive(secondCard),
"Second card should start moving after the first finishes");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Cancel_Queued_Move_Releases_Hold()
{
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);
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);
animator.Tick(0.001f);
Assert.IsTrue(animator.IsCardQueued(cardB));
animator.CancelCard(cardB);
Assert.IsFalse(animator.IsCardQueued(cardB));
cardB.Tick(0.5f);
Assert.Less(
Vector3.Distance(cardB.transform.position, cardB.TargetPosition),
0.5f,
"Canceled queued card should resume normal movement to target");
}
finally
{
animator.CancelAll();
Object.DestroyImmediate(root);
}
}
[Test]
public void Hand_Visibility_Toggles()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var hand = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Hand && card.Ref.Player == 0)
.ToList();
Assert.Greater(hand.Count, 0);
board.SetHandVisible(false);
Assert.IsTrue(hand.All(card => !card.gameObject.activeSelf),
"Hand cards should be hidden");
board.SetHandVisible(true);
Assert.IsTrue(hand.All(card => card.gameObject.activeSelf),
"Hand cards should be visible again");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void CardMoveAnimator_Coalesces_Requests_For_Same_Card()
{

View File

@ -38,6 +38,15 @@ GraphicsSettings:
- {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3}
m_PreloadedShaders: []
m_PreloadShadersBatchTimeLimit: -1
m_GraphicsStateCollection: {fileID: 0}
m_CollectionStartupAction: 0
m_TraceSavePath: TracedCollection
m_TraceSendToEditor: 1
m_AdditionalWarmupCollections: []
m_WarmupAsync: 1
m_EnableCacheMissTracing: 0
m_WarmupProgressivelyLimit: -1
m_CacheMissCollectionPath: CacheMissCollection
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
m_CustomRenderPipeline: {fileID: 0}
m_TransparencySortMode: 0
@ -49,6 +58,7 @@ GraphicsSettings:
m_FogStripping: 0
m_InstancingStripping: 0
m_BrgStripping: 0
m_DefaultLightBaker: 0
m_LightmapKeepPlain: 1
m_LightmapKeepDirCombined: 1
m_LightmapKeepDynamicPlain: 1
@ -61,6 +71,11 @@ GraphicsSettings:
m_AlbedoSwatchInfos: []
m_RenderPipelineGlobalSettingsMap:
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
m_ShaderBuildSettings:
keywordDeclarationOverrides: []
numInternalDefines: 0
defines: []
compilerSettings: []
m_LightsUseLinearIntensity: 1
m_LightsUseColorTemperature: 1
m_LogWhenShaderIsCompiled: 0

View File

@ -9,10 +9,17 @@ UnityConnectSettings:
m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events
m_EventUrl: https://cdp.cloud.unity3d.com/v1/events
m_ConfigUrl: https://config.uca.cloud.unity3d.com
m_DashboardUrl: https://dashboard.unity3d.com
m_TestInitMode: 0
CrashReportingSettings:
m_EventUrl: https://perf-events.cloud.unity3d.com
InsightsSettings:
m_EventUrl: https://engine-data.unity3d.com
m_StackTraceUrl: https://engine-data.services.api.unity.com/v1/stack-traces
m_EngineDiagnosticsEnabled: 0
m_Enabled: 0
CrashReportingSettings:
serializedVersion: 2
m_EventUrl: https://perf-events.cloud.unity3d.com
m_EnableCloudDiagnosticsReporting: 0
m_LogBufferSize: 10
m_CaptureEditorExceptions: 1
UnityPurchasingSettings:
@ -22,6 +29,7 @@ UnityConnectSettings:
m_Enabled: 0
m_TestMode: 0
m_InitializeOnStartup: 1
m_PackageRequiringCoreStatsPresent: 0
UnityAdsSettings:
m_Enabled: 0
m_InitializeOnStartup: 1

View File

@ -0,0 +1,113 @@
# Инвентаризация боевых анимаций
**Дата:** 2026-10-27
**Статус:** `[C]` — фактическое устройство кода; `[R]` — удалено 2026-10-27.
**Назначение:** отметить блоки, которые временно убираем для пересборки.
## Обозначения
- `[C]` — используется сейчас.
- `[R]` — удалено по решению пользователя (2026-10-27).
- `[O]` — отдельной анимации нет; эффект получается побочным путём.
- `[U]` — параметр объявлен в конфиге, но код его не читает.
## Где сейчас лежит общая логика
- `Card3D` — arc-перемещение, `Reveal`, плавный snap, rest/damage-повороты.
- `CardMoveAnimator` — очередь, stagger, последовательный добор, `InputLock`.
- `CameraDirector` — плавное движение камеры (position/rotation/FOV).
- `TriggerCheckSequence` — автомат trigger/damage-check: zoom → reveal → hold → resolve → return.
- `ArrowDirector` / `AttackArrowView` — attack/boost-стрелки.
- `GameBoard3D` — рендер-перемещения, стартовая раздача, reflow руки, trigger-зона.
- `BattleView` — интро, trigger-оркестрация, feedback-очередь и паузы.
## A. Старт боя и раздача
- **A1 [C] Стартовая камера.** `GameBoard3D.SetCameraIntro` мгновенно ставит камеру в `introCameraPosition` / `introCameraTarget` / `introCameraFov`. Держится `introOverheadHold = 1.5 с`.
- **A2 [C] Переезд камеры на основную позицию.** `CameraDirector.MoveTo`, smoothstep, `introCameraMoveDuration = 1.0 с`.
- **A3 [C] Пауза на основной позиции.** `introMainHold = 1.5 с`.
- **A4 [C] Стартовая раздача.** `GameBoard3D.AnimateHandDraw`: руки скрыты, карты лежат над колодами рубашкой вверх; своя и чужая руки идут парами, внутри пары одновременно (`EnqueueNow`), между парами ждём завершения и 0.05 с; `introDrawDuration = 0.6 с`; `InputLock("battle-intro")`.
- **A5 [C] Переворот стартового Авангарда.** `Card3D.Reveal` после муллигана; `vanguardRevealDuration = 0.8 с`; направление `+1` у себя, `-1` у противника; `InputLock("vanguard-reveal")`.
- **A6 [O] Отдельного «открытия стола» нет.** Поле и карты появляются через `Render`/`SetVisible`.
## B. Добор карт
- **B1 [C] Обычный добор.** Новая карта стартует над колодой **полного размера** (`cardScale`), рубашкой вверх в ориентации будущей карты руки, летит в руку по дуге и переворачивается лицом вверх через `StartArcMove` (Slerp). Длительность `introDrawDuration = 0.6 с`, дуга `arcHeight = 0.45`; общий старт с муллиганом через `GetDrawSource`. Очередные карты скрыты, пока не начнётся их собственная дуга — над колодой видна одна.
- **B2 [C] Последовательный добор.** `CardMoveAnimator` с `sequential: true`: следующая карта стартует только после завершения предыдущей; дополнительный stagger `moveDelayBetweenCards = 0.22 с`; `InputLock("card-move")`.
- **B3 [C] Reflow руки.** `GameBoard3D.UpdateHandView` каждый кадр пересчитывает позы; при возврате/доборе используется единый `_visualHandCards`: в нём сразу есть оставшиеся карты, возвращаемые и уже добранные; новые добавляются после завершения дуги. Рука подстраивается после каждого изменения, а не один раз под финальный `HandCount`. Отдельной длительности нет.
- **B4 [O] Отдельного flip-блока добора нет.** `cardFlipDuration` в конфиге не читается; переворот получается из Slerp между рубашкой и лицом.
- **B5 [C] Возврат из руки в колоду.** `GameBoard3D.QueueHandReturnToDeck` по `MulliganView`/`CardReturnedToDeckView`: старая карта руки уходит по дуге под низ колоды, переворачивается рубашкой вверх; длительность — общая `DrawDuration` (`introDrawDuration = 0.6`), `arcHeight = 0.45`; старт справа налево, следующая после завершения предыдущей (`sequential: true`); единый `_visualHandCards` пересчитывается после каждого ухода, включая ожидающие возврата; у противника зеркально. После завершения карта возвращается в пул.
## C. Перемещение карт на столе
- **C1 [C] Обычное arc-перемещение.** Call/Ride/Move и другие несрочные переходы: `moveDuration = 0.8 с`, `arcHeight = 0.45`.
- **C2 [C] Важное arc-перемещение.** Trigger/Damage/Drop: `importantMoveDuration = 1.3 с`.
- **C3 [C] Плавный snap.** Для локальных изменений без arc: rest, damage-поворот, reflow руки, возврат после drag. Экспоненциальное сглаживание, `cardMoveSpeed = 14`, `cardRotateSpeed = 12`.
- **C4 [C] Stagger нескольких карт.** `moveDelayBetweenCards = 0.22 с`; повторный запрос для той же карты отменяет старый.
- **C5 [C] Board-lock.** После рендера с движением `InputLock("board")` держится `cardMoveDuration * 3 + inputLockTail`, минимум 1 с.
- **C6 [C] Drag.** Карта из руки при зажатии ЛКМ **не двигается**: включается `_handTargeting`, карта остаётся в hand-раскладке и hover-подъёме, а курсор только выбирает зону. Цель берётся raycast-ом по карте/кругу (VC-карта сразу даёт Vanguard), затем fallback на плоскость. На release над VC/RC отправляется `CardDropped`; невалидный drop оставляет карту в руке. Карты поля сохраняют обычный drag: подъём `dragLift = 0.14`, следование за курсором и возврат к source до снапшота.
- **C7 [C] Rest / damage-поворот.** Rest — `+90°` вокруг Y, damage — `-90°`; плавный поворот.
- **C8 [O] Отдельной анимации Call/Ride/Retire нет.** Всё идёт общим arc/snap; `callMoveDuration` не читается. `retireDuration` используется только как длительность баннера RETIRE.
## D. Перевороты и раскрытие
- **D1 [C] Обычный face-down/face-up.** `Card3D.FaceDownFlip`, плавный поворот через `SetPose`.
- **D2 [C] Переворот урона.** Вокруг длинной стороны (`Z 180°` в damage-layout), плавный поворот.
- **D3 [C] `Card3D.Reveal`.** Подъём `+0.12`, масштаб `0.85 → 1`, поворот на `180°` вокруг длинной оси, направление `±1`.
- **D4 [C] Раскрытие Авангарда.** Вызывается внутри `GameBoard3D.Render` при переходе `!FaceUp → FaceUp`; 0.8 с.
- **D5 [C] Раскрытие trigger-карты.** `RevealTriggerCard` после завершения зума; `triggerRevealDuration = 0.35 с`, затем пауза.
- **D6 [C] Переворот при доборе.** Побочный Slerp внутри `StartArcMove`; отдельного блока нет.
- **D7 [O] Отдельного `cardFlipDuration`-блока нет.** Параметр не читается.
## E. Камера и триггеры
- **E1 [C] Camera tween.** `CameraDirector` плавно меняет position/rotation/FOV, smoothstep. Используется интро, trigger-зумом и возвратом.
- **E2 [C] Trigger-зум.** `cameraZoomDuration = 0.5 с`, FOV `34`, смещение `back 3.4 / up 2.6`; рука и HUD-баннер скрываются.
- **E3 [C] Полная trigger-последовательность.** Зум 0.5 с → раскрытие 0.35 с → пауза `triggerRevealHold = 0.75 с` → `ResolveReady` → возврат `cameraReturnDuration = 0.5 с`. Выбор цели открывается только после паузы.
- **E4 [C] Damage-check без триггера.** Та же камера/пауза/возврат, но без `Reveal`; в зоне пишется «БЕЗ ТРИГГЕРА».
- **E5 [C] Цепочка триггеров.** `TriggerCheckSequence` умеет повторный `ZoomTo` для следующего триггера; отдельный `cameraRezoomDuration` не читается.
- **E6 [C] Отмена.** `Cancel`/`Restore` мгновенно возвращают камеру и снимают lock.
## F. Атака и бой
- **F1 [C] Красная attack-стрелка.** Появление/рост `growDuration = 0.35 с`, alpha-in `arrowShowDuration = 0.25 с`, fade `ArrowConfig.fadeDuration = 0.2 с`, бегущий пунктир `dashScrollSpeed = 1.5`, дуга `arcHeight = 0.10`.
- **F2 [C] Синие boost-стрелки.** Рост `boostGrowDuration = 0.3 с`, толщина `ArrowConfig.boostThickness = 0.06`; остальное как у attack.
- **F3 [C] Стрелка следует за картами.** Точки пересчитываются каждый кадр; отдельной анимации нет.
- **F4 [R] Feedback-баннер.** Удалён: `BattleFeedback`, `BattleFlash`, кнопка «Пропустить анимации», очередь `FeedbackStep`, `InputLock("feedback")`.
- **F5 [R] Длительности feedback по событиям.** Удалены вместе с баннерами; паузы `eventPause`/`post*Pause` больше не применяются. Темп держат `CardMoveAnimator` и trigger-последовательность.
- **F6 [C] Урон.** Карта идёт в damage-зону важным arc-перемещением 1.3 с, ложится на `-90°`, переворачивается вокруг длинной стороны; счётчик `x/6` меняется мгновенно.
- **F7 [O] Отдельной анимации лечения нет.** Вспышка `HealedView` удалена вместе с feedback; карта урона меняется общим рендером.
- **F8 [O] Отдельной анимации Guard/Intercept/Perfect Guard нет.** Осталось только общее перемещение карт.
- **F9 [O] Нет attack-lunge, shake, hit-частиц, damage-вылета и destroy-эффекта.**
## G. Рука и UI
- **G1 [C] Веер руки.** Своя рука набирается слева направо, новая карта добавляется справа; рука противника зеркальна — новая карта слева. Обе руки пересчитываются каждый кадр, глубина даёт перекрытие. Отдельной длительности нет.
- **G2 [C] Hover карты в руке.** Карта под курсором плавно поднимается камера-вверх на 10% своей высоты (`handHoverLift = 0.1`), после ухода курсора плавно возвращается. Только своя рука; UI/лок/драг/анимации отключают hover.
- **G3 [O] Анимации status-toast нет.** Показ/скрытие мгновенные, живёт 4 с.
- **G4 [O] Анимаций оверлеев и меню нет.** Открываются/закрываются мгновенно.
- **G5 [O] Переходов между сценами нет.** `SceneFlow` переключает сцены без fade.
- **G6 [O] Анимации HUD-счётчиков нет.** Рука/колода/урон/щит/триггер меняются текстом мгновенно.
- **G7 [O] Анимации подсветки нет.** Highlight — мгновенная смена цвета.
- **G8 [O] Анимации изменения силы/крита нет.** Меняется текст/цвет мгновенно.
- **G9 [R] Turn banner.** Мёртвые поля `_turnBanner`/`_turnBannerText` удалены.
## H. Служебные зависимости, не визуальные анимации
- **H1 [C] InputLock.** Ключи: `card-move`, `board`, `trigger-sequence`, `battle-intro`, `vanguard-reveal`, `overlay`, `card-menu`, `ability`, `card-choice`. Ключ `feedback` удалён.
- **H2 [C] Паузы.** Остались только trigger-паузы (`triggerRevealHold = 0.75`); `eventPause`/`post*Pause` больше не используются.
- **H3 [C] Мгновенные скрытия/показы.** Рука скрывается на trigger-фокусе; trigger-зона показывается после зума и сразу `Snap`; trigger-карта появляется без fade.
- **H4 [C] Очистка.** `CardMoveAnimator.CancelAll` и `GameBoard3D.Clear` отменяют очередь и снимают hold/lock.
## I. Параметры, которые сейчас не читаются
- `cardFlipDuration`
- `callMoveDuration`
- `cameraRezoomDuration`
- `arrowHideDuration`
- `arrowFadeDuration` (используется `ArrowConfig.fadeDuration`)
- `boostThickness` в `AnimationConfig` (используется `ArrowConfig.boostThickness`)
- `cardMoveDuration` — только таймаут board-lock, не длительность движения.
- `retireDuration` — был только длительностью баннера RETIRE; после удаления баннеров не читается.
- `attackDeclare`, `guard`, `driveCheck`, `trigger`, `battleResolved`, `damage`, `heal`, `intercept`, `perfectGuard` — были только длительностями feedback-баннеров.
- `eventPause`, `postDrivePause`, `postTriggerPause`, `postDamagePause` — были только паузами feedback-очереди.

View File

@ -111,6 +111,18 @@ public sealed class GameEngine
.Select(a => new TriggeredAbility { Definition = a, Context = context })
.ToList();
// Sentinel попадает в круг защиты: автоматическая способность идеальной
// защиты с оплатой сбросом карты того же клана.
if (trigger == AbilityTrigger.OnGuard
&& State.Catalog.CardOf(source.PrintingId).IsSentinel)
{
triggered.Add(new TriggeredAbility
{
Definition = CreatePerfectGuardAbilityDefinition(source, owner),
Context = context,
});
}
if (triggered.Count == 0)
return;
@ -131,6 +143,75 @@ public sealed class GameEngine
yield return granted.Definition;
}
private AbilityDefinition CreatePerfectGuardAbilityDefinition(CardInstance sentinel, int owner)
{
var clan = State.Catalog.CardOf(sentinel.PrintingId).Clan;
return new AbilityDefinition
{
Trigger = AbilityTrigger.OnGuard,
Optional = true,
Condition = _ => true,
CanPay = ctx =>
{
var player = ctx.State.Players[ctx.Owner];
return player.Hand.Any(c => ctx.State.Catalog.CardOf(c.PrintingId).Clan == clan);
},
Pay = ctx => AskPerfectGuardDiscard(ctx.Owner, sentinel, ctx.State.Battle),
Resolve = ctx => ApplyPerfectGuard(ctx.Owner, sentinel, ctx.State.Battle),
};
}
private void AskPerfectGuardDiscard(int owner, CardInstance sentinel, BattleState? battle)
{
if (battle is null)
return;
var player = State.Players[owner];
var clan = State.Catalog.CardOf(sentinel.PrintingId).Clan;
var options = player.Hand
.Select((card, index) => (card, index))
.Where(item => State.Catalog.CardOf(item.card.PrintingId).Clan == clan)
.Select(item => new ChoiceOption(
item.index,
State.Catalog.CardOf(item.card.PrintingId).NameEn))
.ToList();
State.PendingChoice = new PendingChoice
{
Player = owner,
Prompt = "Выберите карту из руки для сброса",
Options = options,
Kind = ChoiceTargetKind.Hand,
Resolve = index =>
{
ApplyPerfectGuard(owner, sentinel, battle, index);
},
};
State.Step = Step.Choice;
State.ActivePlayer = owner;
}
private void ApplyPerfectGuard(int owner, CardInstance sentinel, BattleState? battle, int? discardHandIndex = null)
{
if (battle is null)
return;
if (discardHandIndex.HasValue)
{
var player = State.Players[owner];
var index = discardHandIndex.Value;
if (index >= 0 && index < player.Hand.Count)
{
var discarded = player.Hand[index];
player.Hand.RemoveAt(index);
player.Drop.Add(discarded);
battle.Nullified = true;
_events.Add(new PerfectGuardEvent(owner, sentinel.PrintingId, discarded.PrintingId));
}
}
}
private void RaiseBatch(List<TriggeredAbility> triggered)
{
if (triggered.Count == 0)
@ -1452,17 +1533,23 @@ public sealed class GameEngine
if (State.PendingTrigger is { } pending)
{
var hasRearGuard = State.Players[pending.Player].RearGuards.Any(u => u is not null);
foreach (var powerSlot in UnitSlots(pending.Player))
{
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
if (pending.Trigger == TriggerType.Critical)
{
foreach (var effectSlot in UnitSlots(pending.Player))
commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
}
else if (pending.Trigger == TriggerType.Stand)
{
if (pending.Trigger == TriggerType.Stand && effectSlot < 0 && hasRearGuard)
continue;
var rested = RestedRearGuardSlots(pending.Player).ToList();
if (rested.Count == 0)
{
commands.Add(new ResolveTriggerCommand(powerSlot, -1));
}
else
{
foreach (var effectSlot in rested)
commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
}
}
@ -1575,7 +1662,7 @@ public sealed class GameEngine
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
&& (slot is 3 or 4
|| (canSnipe && SameColumn(attackerSlot, slot)))));
|| (canSnipe && SameColumnAcrossPlayers(attackerSlot, slot)))));
foreach (var target in targets)
commands.Add(new AttackCommand(attackerSlot, target));
@ -1614,16 +1701,9 @@ public sealed class GameEngine
if (def.IsSentinel)
{
// Оплата Sentinel теперь идёт отдельной автоматической способностью
// после размещения в круг защиты.
commands.Add(new GuardCommand(printingId));
foreach (var discardId in p.Hand
.Where(c => c.PrintingId != printingId
&& State.Catalog.CardOf(c.PrintingId).Clan == def.Clan)
.Select(c => c.PrintingId)
.Distinct())
{
commands.Add(new GuardCommand(printingId, discardId));
}
}
else if (def.Shield > 0)
{
@ -1722,6 +1802,17 @@ public sealed class GameEngine
}
}
/// <summary>Круги RC, которые можно поднять Stand-триггером.</summary>
private IEnumerable<int> RestedRearGuardSlots(int player)
{
for (var slot = 0; slot < PlayerState.RearGuardSlots; slot++)
{
var unit = State.Players[player].RearGuards[slot];
if (unit is not null && unit.Rested)
yield return slot;
}
}
// ---------- старт партии ----------
public void Start(int? firstPlayer = null)
@ -1788,7 +1879,8 @@ public sealed class GameEngine
var card = p.Deck[index];
p.Deck.RemoveAt(index);
card.FaceUp = true;
// Стартовый Авангард лежит закрытым до конца муллигана.
card.FaceUp = false;
p.Vanguard = card;
}
@ -1891,6 +1983,13 @@ public sealed class GameEngine
return CommandResult.Ok();
}
// Оба муллигана завершены: открываем стартовые Авангарды.
for (var i = 0; i < 2; i++)
{
if (State.Players[i].Vanguard is { } vanguard)
vanguard.FaceUp = true;
}
State.ActivePlayer = State.FirstPlayer;
StartTurn();
return CommandResult.Ok();
@ -2006,6 +2105,14 @@ public sealed class GameEngine
(fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2)
or (-1, 1) or (1, -1);
/// <summary>
/// Колонки для атаки через границу игроков с учётом зеркального
/// отображения половины противника на клиенте. Атаковать могут только
/// V и передний ряд: центр V → 1, левый RC 3 → 2, правый RC 4 → 0.
/// </summary>
private static bool SameColumnAcrossPlayers(int attackerSlot, int defenderSlot) =>
(attackerSlot, defenderSlot) is (-1, 1) or (3, 2) or (4, 0);
private CommandResult EnterBattle()
{
if (State.Phase != Phase.Main)
@ -2062,7 +2169,7 @@ public sealed class GameEngine
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
if (!SameColumn(attackerSlot, targetSlot))
if (!SameColumnAcrossPlayers(attackerSlot, targetSlot))
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
}
@ -2187,17 +2294,8 @@ public sealed class GameEngine
if (!def.IsSentinel && discardPrintingId is not null)
return CommandResult.Reject("Эта карта не требует оплаты стоимости.");
if (def.IsSentinel && discardPrintingId is not null)
{
var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
if (discardIndex < 0)
return CommandResult.Reject("Карты для оплаты нет в руке.");
if (State.Catalog.CardOf(defender.Hand[discardIndex].PrintingId).Clan != def.Clan)
return CommandResult.Reject("Для оплаты нужна карта того же клана.");
}
// Sentinel сам по себе даёт ноль щита: либо оплата и отмена атаки, либо ничего
// Sentinel сам по себе даёт ноль щита: оплата и отмена атаки разрешается
// отдельной автоматической способностью после размещения в круг защиты.
var shield = def.IsSentinel ? 0 : def.Shield;
defender.Hand.RemoveAt(index);
@ -2207,17 +2305,6 @@ public sealed class GameEngine
battle.GuardShield += shield;
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, shield));
if (def.IsSentinel && discardPrintingId is not null)
{
var discardIndex = defender.Hand.FindIndex(c => c.PrintingId == discardPrintingId);
var discarded = defender.Hand[discardIndex];
defender.Hand.RemoveAt(discardIndex);
defender.Drop.Add(discarded);
battle.Nullified = true;
_events.Add(new PerfectGuardEvent(defenderIndex, card.PrintingId, discarded.PrintingId));
}
TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null);
// способность на размещение в (GC) может встать на выбор игрока:
@ -2478,17 +2565,24 @@ public sealed class GameEngine
return CommandResult.Reject("Нет цели для +5000 силы.");
}
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand)
if (pending.Trigger == TriggerType.Critical)
{
if (State.GetUnit(player, command.EffectTarget) is null)
return CommandResult.Reject("Нет цели для Крит +1.");
}
else if (pending.Trigger == TriggerType.Stand)
{
if (command.EffectTarget < 0)
{
// Если есть повёрнутый RC, игрок обязан выбрать его.
if (RestedRearGuardSlots(player).Any())
return CommandResult.Reject("Выберите повёрнутую карту в RC для подъёма.");
}
else
{
var effectUnit = State.GetUnit(player, command.EffectTarget);
if (effectUnit is null)
return CommandResult.Reject("Нет цели для второго эффекта.");
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0)
{
// если Арьергардов нет, поднимать нечего — второй эффект не применяется
if (State.Players[player].RearGuards.Any(u => u is not null))
return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда.");
if (effectUnit is null || !effectUnit.Rested)
return CommandResult.Reject("Поднять можно только повёрнутую карту в RC.");
}
}

View File

@ -78,7 +78,7 @@ public static class MatchProjection
Vanguard = player.Vanguard is null
? null
: ProjectCard(engine, player.Vanguard, forceFaceUp: true),
: ProjectCard(engine, player.Vanguard),
RearGuards = player.RearGuards
.Select(card => card is null
? null

View File

@ -153,6 +153,24 @@ public class GameEngineTests
engine.State.Catalog.CardOf(engine.State.Players[0].Vanguard!.PrintingId).Grade);
}
[Fact]
public void Starting_Vanguard_Is_Face_Down_Until_Both_Mulligans_Finish()
{
var engine = NewEngine();
engine.Start(firstPlayer: 0);
Assert.False(engine.State.Players[0].Vanguard!.FaceUp);
Assert.False(engine.State.Players[1].Vanguard!.FaceUp);
Assert.True(engine.Execute(new FinishMulliganCommand()).Accepted);
Assert.False(engine.State.Players[0].Vanguard.FaceUp);
Assert.False(engine.State.Players[1].Vanguard.FaceUp);
Assert.True(engine.Execute(new FinishMulliganCommand()).Accepted);
Assert.True(engine.State.Players[0].Vanguard.FaceUp);
Assert.True(engine.State.Players[1].Vanguard.FaceUp);
}
[Fact]
public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
{
@ -428,6 +446,22 @@ public class GameEngineTests
Assert.False(rearGuard.Rested);
}
[Fact]
public void Stand_Trigger_Rejects_Non_Rested_RearGuard()
{
var engine = ReadyToAttackWithRearGuard();
StackDeck(engine.State.Players[0], Flogal, Gallatin);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.NotNull(engine.State.PendingTrigger);
var rearGuard = engine.State.Players[0].RearGuards[0]!;
rearGuard.Rested = false;
Assert.False(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
}
[Fact]
public void Damage_Trigger_Also_Asks_For_Targets_And_Applies_Effect()
{
@ -596,7 +630,19 @@ public class GameEngineTests
Give(engine, 1, Marron);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Iseult, Marron)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
// Sentinel создаёт опциональную способность; принимаем её.
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
// Выбираем карту того же клана из руки для сброса.
Assert.NotNull(engine.State.PendingChoice);
var marronIndex = engine.State.Players[1].Hand
.FindIndex(c => c.PrintingId == Marron);
Assert.True(marronIndex >= 0);
Assert.True(engine.Execute(new ChooseCommand(marronIndex)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Empty(engine.State.Players[1].Damage);
@ -611,9 +657,31 @@ public class GameEngineTests
SetVanguard(engine, 1, Marron);
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
Give(engine, 1, Iseult);
// Оставляем в руке только Sentinel: оплачивать способность нечем.
engine.State.Players[1].Hand.RemoveAll(c => c.PrintingId != Iseult);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
var pass = engine.Execute(new PassGuardCommand());
Assert.True(pass.Accepted, pass.Reason);
Assert.Single(engine.State.Players[1].Damage);
Assert.DoesNotContain(engine.Events, e => e is PerfectGuardEvent);
}
[Fact]
public void Perfect_Guard_Decline_Keeps_Attack()
{
var engine = ReadyToAttack();
SetVanguard(engine, 1, Marron);
Give(engine, 1, Iseult);
Give(engine, 1, Marron);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(false)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Single(engine.State.Players[1].Damage);
@ -621,14 +689,29 @@ public class GameEngineTests
}
[Fact]
public void Perfect_Guard_Rejects_Wrong_Clan_Cost()
public void Perfect_Guard_Offers_Only_Same_Clan_Cards()
{
var engine = ReadyToAttack();
SetVanguard(engine, 1, Marron);
Give(engine, 1, Iseult);
Give(engine, 1, Marron);
Give(engine, 1, Nehalem);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.False(engine.Execute(new GuardCommand(Iseult, Nehalem)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Iseult)).Accepted);
Assert.NotNull(engine.State.PendingAbility);
Assert.True(engine.Execute(new ResolveAbilityCommand(true)).Accepted);
var choice = engine.State.PendingChoice;
Assert.NotNull(choice);
Assert.All(choice.Options, option =>
{
var card = engine.State.Players[1].Hand[option.Slot];
Assert.Equal(
engine.State.Catalog.CardOf(Iseult).Clan,
engine.State.Catalog.CardOf(card.PrintingId).Clan);
});
}
[Fact]
@ -1996,15 +2079,48 @@ public class GameEngineTests
engine.State.Players[0].RearGuards[3] = tejas;
tejas.Rested = false;
var defender = engine.State.Players[1];
defender.RearGuards[2] = MakeCard(Barcgal);
Assert.True(engine.Execute(new AttackCommand(3, 2)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(defender.RearGuards[2]);
}
[Fact]
public void Tejas_Attacks_Back_Row_Right_Column()
{
var engine = ReadyToAttackRearGuard();
var tejas = MakeCard(Tejas);
engine.State.Players[0].RearGuards[4] = tejas;
tejas.Rested = false;
var defender = engine.State.Players[1];
defender.RearGuards[0] = MakeCard(Barcgal);
Assert.True(engine.Execute(new AttackCommand(3, 0)).Accepted);
Assert.True(engine.Execute(new AttackCommand(4, 0)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(defender.RearGuards[0]);
}
[Fact]
public void Tejas_Cannot_Attack_Opposite_Back_Row_Column()
{
var engine = ReadyToAttackRearGuard();
var tejas = MakeCard(Tejas);
engine.State.Players[0].RearGuards[3] = tejas;
tejas.Rested = false;
var defender = engine.State.Players[1];
defender.RearGuards[0] = MakeCard(Barcgal);
Assert.False(engine.Execute(new AttackCommand(3, 0)).Accepted);
}
[Fact]
public void Tejas_On_Vanguard_Attacks_Back_Row_Center()
{