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

View File

@ -72,7 +72,22 @@ MonoBehaviour:
- rid: 6588237625532874761 - rid: 6588237625532874761
- rid: 6588237625532874762 - rid: 6588237625532874762
m_RuntimeSettings: 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_AssetVersion: 11
m_ObsoleteDefaultVolumeProfile: {fileID: 0} m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames: m_RenderingLayerNames:

View File

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

View File

@ -68,6 +68,41 @@ namespace Vanguard.Battle
duration); 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) public void ReturnToOrigin(float duration)
{ {
if (!_hasOrigin) if (!_hasOrigin)

View File

@ -1,3 +1,4 @@
using System;
using UnityEngine; using UnityEngine;
using Vanguard.Battle; using Vanguard.Battle;
@ -13,6 +14,7 @@ namespace Vanguard.Battle
{ {
Idle, Idle,
Zooming, Zooming,
Revealing,
Holding, Holding,
Returning, Returning,
} }
@ -22,6 +24,9 @@ namespace Vanguard.Battle
private Stage _stage; private Stage _stage;
private float _deadline; private float _deadline;
private float _zoomDeadline;
private float _revealDeadline;
private float _resolveDeadline;
private bool _chainActive; private bool _chainActive;
private bool _restoreRequested; private bool _restoreRequested;
private bool _lockHeld; private bool _lockHeld;
@ -36,6 +41,18 @@ namespace Vanguard.Battle
public bool IsBusy => _stage != Stage.Idle; 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( public void ShowTrigger(
AnimationConfig config, AnimationConfig config,
Vector3 worldPoint) Vector3 worldPoint)
@ -43,6 +60,8 @@ namespace Vanguard.Battle
if (_camera == null) if (_camera == null)
return; return;
var wasIdle = _stage == Stage.Idle;
if (!_chainActive) if (!_chainActive)
{ {
_camera.BeginChain(); _camera.BeginChain();
@ -54,13 +73,21 @@ namespace Vanguard.Battle
var zoom = config != null var zoom = config != null
? config.cameraZoomDuration ? config.cameraZoomDuration
: 0.35f; : 0.35f;
var hold = config != null var reveal = config != null
? config.triggerRevealDuration
: 0.35f;
var pause = config != null
? config.triggerRevealHold ? config.triggerRevealHold
: 1.2f; : 0.75f;
_camera.ZoomTo(worldPoint, zoom); _camera.ZoomTo(worldPoint, zoom);
_stage = Stage.Zooming; _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) public void ReturnForChoice(AnimationConfig config)
@ -99,15 +126,30 @@ namespace Vanguard.Battle
return; return;
if (_stage == Stage.Zooming 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 var duration = config != null
? config.cameraReturnDuration ? config.cameraReturnDuration
: 0.4f; : 0.4f;
_restoreRequested = true;
_camera.ReturnToOrigin(duration); _camera.ReturnToOrigin(duration);
_stage = Stage.Returning; _stage = Stage.Returning;
_deadline = Time.unscaledTime + duration; _deadline = Time.unscaledTime + duration;
return;
} }
if (_stage == Stage.Returning if (_stage == Stage.Returning
@ -121,6 +163,8 @@ namespace Vanguard.Battle
_chainActive = false; _chainActive = false;
_camera.Restore(); _camera.Restore();
} }
OnFocusChanged(false);
} }
} }
@ -144,10 +188,18 @@ namespace Vanguard.Battle
public void Cancel() public void Cancel()
{ {
var wasIdle = _stage == Stage.Idle;
_stage = Stage.Idle; _stage = Stage.Idle;
_chainActive = false; _chainActive = false;
Release(); Release();
_camera?.Restore(); _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 System.Collections.Generic;
using UnityEngine; using UnityEngine;
using Vanguard.Battle; using Vanguard.Battle;
@ -19,12 +20,17 @@ namespace Vanguard.Board3D
public Vector3 FromScale; public Vector3 FromScale;
public float Duration; public float Duration;
public float ArcHeight; public float ArcHeight;
public bool Sequential;
public Action<Card3D> OnCompleted;
public Action<Card3D> OnStart;
public bool FromCurrent;
} }
private sealed class ActiveMove private sealed class ActiveMove
{ {
public Card3D Card; public Card3D Card;
public float Remaining; public float Remaining;
public Action<Card3D> OnCompleted;
} }
private readonly Queue<Request> _queue = new Queue<Request>(); private readonly Queue<Request> _queue = new Queue<Request>();
@ -86,7 +92,11 @@ namespace Vanguard.Board3D
Quaternion fromRotation, Quaternion fromRotation,
Vector3 fromScale, Vector3 fromScale,
float duration, 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) if (card == null || card.IsDragging)
return; return;
@ -104,9 +114,47 @@ namespace Vanguard.Board3D
FromScale = fromScale, FromScale = fromScale,
Duration = duration, Duration = duration,
ArcHeight = arcHeight, 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) public void Tick(float deltaTime)
{ {
if (_active.Count > 0) if (_active.Count > 0)
@ -130,6 +178,7 @@ namespace Vanguard.Board3D
if (!card.IsMoving) if (!card.IsMoving)
{ {
_active.RemoveAt(i); _active.RemoveAt(i);
active.OnCompleted?.Invoke(card);
continue; continue;
} }
@ -139,6 +188,7 @@ namespace Vanguard.Board3D
{ {
card.StopMove(snap: true); card.StopMove(snap: true);
_active.RemoveAt(i); _active.RemoveAt(i);
active.OnCompleted?.Invoke(card);
} }
} }
} }
@ -156,6 +206,12 @@ namespace Vanguard.Board3D
public void CancelAll(bool snapCards = false) public void CancelAll(bool snapCards = false)
{ {
foreach (var request in _queue)
{
if (request?.Card != null)
request.Card.CancelHold();
}
_queue.Clear(); _queue.Clear();
foreach (var active in _active) foreach (var active in _active)
{ {
@ -173,17 +229,27 @@ namespace Vanguard.Board3D
if (card == null) if (card == null)
return; return;
var wasQueued = false;
var remaining = new Queue<Request>(); var remaining = new Queue<Request>();
while (_queue.Count > 0) while (_queue.Count > 0)
{ {
var request = _queue.Dequeue(); var request = _queue.Dequeue();
if (request.Card != card) if (request.Card != card)
{
remaining.Enqueue(request); remaining.Enqueue(request);
}
else
{
wasQueued = true;
}
} }
while (remaining.Count > 0) while (remaining.Count > 0)
_queue.Enqueue(remaining.Dequeue()); _queue.Enqueue(remaining.Dequeue());
if (wasQueued)
card.CancelHold();
for (var i = _active.Count - 1; i >= 0; i--) for (var i = _active.Count - 1; i >= 0; i--)
{ {
if (_active[i]?.Card != card) if (_active[i]?.Card != card)
@ -202,6 +268,14 @@ namespace Vanguard.Board3D
if (_queue.Count == 0) if (_queue.Count == 0)
return; return;
// Последовательные запросы (добор, возврат в колоду) идут строго
// по одной карте: следующая стартует только после предыдущей.
if (_queue.Peek().Sequential && _active.Count > 0)
{
_delay = 0f;
return;
}
var request = _queue.Dequeue(); var request = _queue.Dequeue();
var card = request.Card; var card = request.Card;
if (card == null || card.IsDragging) if (card == null || card.IsDragging)
@ -210,10 +284,18 @@ namespace Vanguard.Board3D
return; return;
} }
request.OnStart?.Invoke(card);
card.StartArcMove( card.StartArcMove(
request.FromPosition, request.FromCurrent
request.FromRotation, ? card.transform.position
request.FromScale, : request.FromPosition,
request.FromCurrent
? card.transform.rotation
: request.FromRotation,
request.FromCurrent
? card.transform.localScale
: request.FromScale,
request.Duration, request.Duration,
request.ArcHeight); request.ArcHeight);
@ -223,8 +305,13 @@ namespace Vanguard.Board3D
{ {
Card = card, Card = card,
Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f, Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f,
OnCompleted = request.OnCompleted,
}); });
} }
else
{
request.OnCompleted?.Invoke(card);
}
_delay = Mathf.Max(0.01f, _config != null _delay = Mathf.Max(0.01f, _config != null
? _config.moveDelayBetweenCards ? _config.moveDelayBetweenCards

View File

@ -30,27 +30,34 @@ MonoBehaviour:
dragThresholdPixels: 7 dragThresholdPixels: 7
smoothMoveSpeed: 16 smoothMoveSpeed: 16
smoothRotateSpeed: 14 smoothRotateSpeed: 14
handHoverLift: 0.08 handHoverLift: 0.1
handCenterZ: -5.3 handCenterZ: -5.3
handHeight: 1.12 handHeight: 1.12
handTilt: 58 handTilt: 58
handSpacing: 0.42 handSpacing: 0.28
handMaxWidth: 7.4 handMaxWidth: 7.4
handArc: 0.035 handArc: 0.035
handViewportX: 0.44 handViewportX: 0.5
handViewportY: 0.1 handViewportY: 0.1
handViewDistance: 4 handViewDistance: 4
handViewMaxWidth: 1.4 handViewMaxWidth: 2.52
handViewScale: 0.5 handViewScale: 0.5
handViewRoll: 3.2 handViewRoll: 3.2
handViewArc: 0.02 handViewArc: 0.02
opponentHandEnabled: 1 opponentHandEnabled: 1
opponentHandZ: 4.3 opponentHandZ: 4.3
opponentHandHeight: 0.75 opponentHandHeight: 0.75
opponentHandSpacing: 0.42 opponentHandSpacing: 0.28
opponentHandMaxWidth: 2.4 opponentHandMaxWidth: 3.78
opponentHandScale: 0.8 opponentHandScale: 0.9
opponentHandRoll: 2.5 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 guardianRowGap: 0.55
guardianSpacing: 0.62 guardianSpacing: 0.62
guardianMaxWidth: 3.6 guardianMaxWidth: 3.6
@ -60,6 +67,9 @@ MonoBehaviour:
cameraPosition: {x: 0, y: 4.2, z: -8} cameraPosition: {x: 0, y: 4.2, z: -8}
cameraTarget: {x: 0, y: 0, z: 0} cameraTarget: {x: 0, y: 0, z: 0}
cameraFov: 46 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} tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, 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} 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 dragThresholdPixels = 7f;
public float smoothMoveSpeed = 16f; public float smoothMoveSpeed = 16f;
public float smoothRotateSpeed = 14f; public float smoothRotateSpeed = 14f;
public float handHoverLift = 0.08f; [Tooltip("Подъём карты при наведении, доля её высоты.")]
[Range(0f, 0.5f)] public float handHoverLift = 0.1f;
[Header("Рука на столе (fallback без камеры)")] [Header("Рука на столе (fallback без камеры)")]
public float handCenterZ = -5.3f; public float handCenterZ = -5.3f;
public float handHeight = 1.12f; public float handHeight = 1.12f;
[Range(0f, 85f)] public float handTilt = 58f; [Range(0f, 85f)] public float handTilt = 58f;
public float handSpacing = 0.42f; public float handSpacing = 0.28f;
public float handMaxWidth = 7.4f; public float handMaxWidth = 7.4f;
public float handArc = 0.035f; public float handArc = 0.035f;
[Header("Рука в поле зрения")] [Header("Рука в поле зрения")]
[Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")] [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 — середина.")] [Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")]
[Range(0.05f, 0.45f)] public float handViewportY = 0.10f; [Range(0.05f, 0.45f)] public float handViewportY = 0.10f;
[Tooltip("Расстояние от камеры до веера руки, метры.")] [Tooltip("Расстояние от камеры до веера руки, метры.")]
public float handViewDistance = 4.0f; public float handViewDistance = 4.0f;
public float handViewMaxWidth = 1.4f; public float handViewMaxWidth = 2.52f;
public float handViewScale = 0.5f; public float handViewScale = 0.5f;
public float handViewRoll = 3.2f; public float handViewRoll = 3.2f;
public float handViewArc = 0.02f; public float handViewArc = 0.02f;
@ -67,10 +68,19 @@ namespace Vanguard.Board3D
public bool opponentHandEnabled = true; public bool opponentHandEnabled = true;
public float opponentHandZ = 4.3f; public float opponentHandZ = 4.3f;
public float opponentHandHeight = 0.75f; public float opponentHandHeight = 0.75f;
public float opponentHandSpacing = 0.42f; public float opponentHandSpacing = 0.28f;
public float opponentHandMaxWidth = 2.4f; public float opponentHandMaxWidth = 3.78f;
public float opponentHandScale = 0.8f; public float opponentHandScale = 0.9f;
public float opponentHandRoll = 2.5f; 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("Круг защиты")] [Header("Круг защиты")]
public float guardianRowGap = 0.55f; public float guardianRowGap = 0.55f;
@ -80,6 +90,8 @@ namespace Vanguard.Board3D
[Header("Стопки")] [Header("Стопки")]
[Tooltip("Расстояние от центра стола до зоны урона.")] [Tooltip("Расстояние от центра стола до зоны урона.")]
public float damageX = 3.0f; public float damageX = 3.0f;
[Tooltip("Сдвиг следующей карты урона в долях ширины карты. 0.3 = новая карта перекрывает 30% предыдущей.")]
[Range(0.1f, 0.9f)] public float damageStackOffset = 0.3f;
[Tooltip("Расстояние от центра стола до колоды и дропа.")] [Tooltip("Расстояние от центра стола до колоды и дропа.")]
public float pileX = 2.95f; public float pileX = 2.95f;
[Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")] [Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")]
@ -95,6 +107,12 @@ namespace Vanguard.Board3D
public Vector3 cameraTarget = new Vector3(0f, 0f, 0f); public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
public float cameraFov = 46f; 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("Палитра")] [Header("Палитра")]
public Color tableColor = new Color(0.045f, 0.04f, 0.055f); public Color tableColor = new Color(0.045f, 0.04f, 0.055f);
public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f); public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f);
@ -156,6 +174,8 @@ namespace Vanguard.Board3D
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth); guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f); handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f);
handViewportX = Mathf.Clamp(handViewportX, 0.05f, 0.95f); 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); handViewDistance = Mathf.Max(1f, handViewDistance);
handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth); handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth);
handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f); handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f);
@ -167,6 +187,7 @@ namespace Vanguard.Board3D
opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth); opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth);
opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f); opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f);
opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f); opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f);
opponentHandArc = Mathf.Max(0f, opponentHandArc);
} }
} }
} }

View File

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

View File

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

View File

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

View File

@ -59,6 +59,36 @@ namespace Vanguard.Board3D
view._renderer = renderer; view._renderer = renderer;
view.ApplyColor(false); 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>(); var collider = go.AddComponent<BoxCollider>();
collider.size = new Vector3( collider.size = new Vector3(
config.cardWidth * 1.12f, config.cardWidth * 1.12f,

View File

@ -13,6 +13,7 @@ namespace Vanguard.Board3D
private MeshRenderer _front; private MeshRenderer _front;
private MeshRenderer _back; private MeshRenderer _back;
private MeshRenderer _edge; private MeshRenderer _edge;
private MeshRenderer _frame;
private MeshRenderer _highlight; private MeshRenderer _highlight;
private BoxCollider _collider; private BoxCollider _collider;
private MaterialPropertyBlock _block; private MaterialPropertyBlock _block;
@ -35,6 +36,20 @@ namespace Vanguard.Board3D
private float _arcElapsed; private float _arcElapsed;
private float _arcHeight; 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 bool _dragging;
private Vector3 _dragOriginPosition; private Vector3 _dragOriginPosition;
private Quaternion _dragOriginRotation; private Quaternion _dragOriginRotation;
@ -48,9 +63,20 @@ namespace Vanguard.Board3D
public Vector3 TargetPosition => _targetPosition; public Vector3 TargetPosition => _targetPosition;
public float TargetYaw => _targetRotation.eulerAngles.y; public float TargetYaw => _targetRotation.eulerAngles.y;
public Quaternion TargetRotation => _targetRotation; 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 => public bool IsAnimating =>
!_dragging !_dragging
&& (_arcMoving && (_arcMoving
|| _revealActive
|| Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f || Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f || Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f); || Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
@ -97,6 +123,21 @@ namespace Vanguard.Board3D
_front.sharedMaterial = BoardMaterials.White; _front.sharedMaterial = BoardMaterials.White;
BoardMaterials.SetShadows(_front, false); 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"); var backGo = new GameObject("Back");
backGo.transform.SetParent(transform, false); backGo.transform.SetParent(transform, false);
backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f); backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f);
@ -108,12 +149,15 @@ namespace Vanguard.Board3D
var highlightGo = new GameObject("Highlight"); var highlightGo = new GameObject("Highlight");
highlightGo.transform.SetParent(transform, false); 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>(); var filter = highlightGo.AddComponent<MeshFilter>();
filter.sharedMesh = BoardMeshLibrary.Frame( filter.sharedMesh = BoardMeshLibrary.Frame(
width * 1.16f, width,
height * 1.16f, height,
Mathf.Max(0.025f, width * 0.035f)); Mathf.Max(0.006f, width * 0.009f));
_highlight = highlightGo.AddComponent<MeshRenderer>(); _highlight = highlightGo.AddComponent<MeshRenderer>();
_highlight.sharedMaterial = BoardMaterials.Highlight; _highlight.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(_highlight, false); BoardMaterials.SetShadows(_highlight, false);
@ -122,7 +166,7 @@ namespace Vanguard.Board3D
_stats = BoardText.Create( _stats = BoardText.Create(
transform, transform,
string.Empty, string.Empty,
0.028f, 0.034f,
Color.white, Color.white,
Vector3.zero); Vector3.zero);
_stats.gameObject.SetActive(false); _stats.gameObject.SetActive(false);
@ -140,7 +184,9 @@ namespace Vanguard.Board3D
name = "Card3D " + reference; name = "Card3D " + reference;
} }
public void SetArt(Texture2D texture, Color accent) public void SetArt(
Texture2D texture,
Color accent)
{ {
if (_front == null) if (_front == null)
return; return;
@ -156,6 +202,19 @@ namespace Vanguard.Board3D
_front.sharedMaterial, _front.sharedMaterial,
texture != null ? Color.white : accent); texture != null ? Color.white : accent);
_front.SetPropertyBlock(_block); _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) public void SetAnimationSpeeds(float moveSpeed, float rotateSpeed)
@ -190,7 +249,7 @@ namespace Vanguard.Board3D
_stats.transform.position = new Vector3( _stats.transform.position = new Vector3(
position.x, position.x,
position.y + 0.015f, position.y + 0.015f,
position.z - (_config.cardHeight * 0.62f)); position.z - (_config.cardHeight * 0.68f));
_stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f); _stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
} }
@ -207,7 +266,13 @@ namespace Vanguard.Board3D
// Атака/Rest — по часовой стрелке (+90°), урон лежит против (-90°). // Атака/Rest — по часовой стрелке (+90°), урон лежит против (-90°).
var restYaw = rested ? (damageLayout ? -90f : 90f) : 0f; var restYaw = rested ? (damageLayout ? -90f : 90f) : 0f;
var rest = Quaternion.Euler(0f, restYaw, 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; _targetRotation = rest * baseRotation * flip;
} }
@ -240,11 +305,60 @@ namespace Vanguard.Board3D
{ {
_arcMoving = false; _arcMoving = false;
_immediate = false; _immediate = false;
_hold = false;
_revealActive = false;
transform.position = position; transform.position = position;
transform.rotation = rotation; transform.rotation = rotation;
transform.localScale = scale; 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( public void StartArcMove(
Vector3 fromPosition, Vector3 fromPosition,
Quaternion fromRotation, Quaternion fromRotation,
@ -257,6 +371,8 @@ namespace Vanguard.Board3D
_arcMoving = false; _arcMoving = false;
_immediate = false; _immediate = false;
_hold = false;
_revealActive = false;
var distance = Vector3.Distance(fromPosition, _targetPosition); var distance = Vector3.Distance(fromPosition, _targetPosition);
var angle = Quaternion.Angle(fromRotation, _targetRotation); var angle = Quaternion.Angle(fromRotation, _targetRotation);
@ -283,10 +399,12 @@ namespace Vanguard.Board3D
public void StopMove(bool snap) public void StopMove(bool snap)
{ {
if (!_arcMoving) if (!_arcMoving && !_hold && !_revealActive)
return; return;
_arcMoving = false; _arcMoving = false;
_hold = false;
_revealActive = false;
if (snap) if (snap)
Snap(); Snap();
} }
@ -318,6 +436,8 @@ namespace Vanguard.Board3D
return; return;
_dragging = true; _dragging = true;
_hold = false;
_revealActive = false;
_dragOriginPosition = transform.position; _dragOriginPosition = transform.position;
_dragOriginRotation = transform.rotation; _dragOriginRotation = transform.rotation;
_targetScale = _baseScale * 1.05f; _targetScale = _baseScale * 1.05f;
@ -326,23 +446,53 @@ namespace Vanguard.Board3D
SetHighlight(null); SetHighlight(null);
} }
public float DragLift => Ref.Zone == BoardZone.Hand
? _config.cardHeight * _baseScale.x * _config.handHoverLift
: _config.dragLift;
public void DragTo(Vector3 worldPosition) public void DragTo(Vector3 worldPosition)
{ {
if (!_dragging) if (!_dragging)
return; return;
var yaw = _targetRotation.eulerAngles.y; // Сохраняем исходную ориентацию карты: карта из руки остаётся
// лицом к игроку, а не «ложится» на стол при перетаскивании.
// Высота та же, что у hover: 10% высоты карты.
transform.position = new Vector3( transform.position = new Vector3(
worldPosition.x, worldPosition.x,
_config.dragLift + (_config.cardThickness * 0.5f), DragLift + (_config.cardThickness * 0.5f),
worldPosition.z); worldPosition.z);
transform.rotation = Quaternion.Euler(0f, yaw, 0f); transform.rotation = _dragOriginRotation;
transform.localScale = _baseScale * 1.05f; 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) public void DropAt(Vector3 position, Quaternion rotation)
{ {
_dragging = false; _dragging = false;
_hold = false;
_revealActive = false;
if (_collider != null) if (_collider != null)
_collider.enabled = true; _collider.enabled = true;
_targetPosition = position; _targetPosition = position;
@ -354,6 +504,8 @@ namespace Vanguard.Board3D
public void ReturnToOrigin() public void ReturnToOrigin()
{ {
_dragging = false; _dragging = false;
_hold = false;
_revealActive = false;
if (_collider != null) if (_collider != null)
_collider.enabled = true; _collider.enabled = true;
_targetPosition = _dragOriginPosition; _targetPosition = _dragOriginPosition;
@ -365,6 +517,8 @@ namespace Vanguard.Board3D
public void Snap() public void Snap()
{ {
_arcMoving = false; _arcMoving = false;
_hold = false;
_revealActive = false;
transform.position = _targetPosition; transform.position = _targetPosition;
transform.rotation = _targetRotation; transform.rotation = _targetRotation;
transform.localScale = _targetScale; transform.localScale = _targetScale;
@ -376,6 +530,36 @@ namespace Vanguard.Board3D
if (_dragging || deltaTime <= 0f) if (_dragging || deltaTime <= 0f)
return; 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) if (_arcMoving)
{ {
_arcElapsed += deltaTime; _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"); .First(go => go.name == "BattleScreen");
screen.SetActive(true); screen.SetActive(true);
var self = ReferenceRect(FindRect("ZoneBlocksSelf")); var self = ReferenceRect(FindRect("PlayerStatsSelf"));
var opponent = ReferenceRect(FindRect("ZoneBlocksOpponent")); var opponent = ReferenceRect(FindRect("PlayerStatsOpponent"));
var phase = ReferenceRect(FindRect("PhasePanel"));
var stats = ReferenceRect(FindRect("CombatStats")); var stats = ReferenceRect(FindRect("CombatStats"));
AssertWithinScreen(self, "self zones"); AssertWithinScreen(self, "self stats");
AssertWithinScreen(opponent, "opponent zones"); AssertWithinScreen(opponent, "opponent stats");
AssertWithinScreen(phase, "phase panel");
AssertWithinScreen(stats, "combat stats"); AssertWithinScreen(stats, "combat stats");
Assert.IsFalse(self.Overlaps(opponent)); Assert.IsFalse(self.Overlaps(opponent));
Assert.IsFalse(self.Overlaps(phase));
Assert.IsFalse(opponent.Overlaps(phase));
Assert.IsFalse(opponent.Overlaps(stats)); Assert.IsFalse(opponent.Overlaps(stats));
Assert.IsFalse(phase.Overlaps(stats));
var toggle = Resources.FindObjectsOfTypeAll<Button>() var toggle = Resources.FindObjectsOfTypeAll<Button>()
.First(button => button.name == "ToggleIntentPanel" .First(button => button.name == "ToggleIntentPanel"
&& button.gameObject.scene.IsValid()); && button.gameObject.scene.IsValid());
toggle.onClick.Invoke(); toggle.onClick.Invoke();
var movedOpponent = ReferenceRect(FindRect("ZoneBlocksOpponent")); var debug = FindRect("DebugPanel");
var movedPhase = ReferenceRect(FindRect("PhasePanel")); Assert.IsTrue(debug.gameObject.activeSelf, "Debug panel should open by toggle");
Assert.Greater(movedOpponent.xMin, opponent.xMin); AssertWithinScreen(ReferenceRect(debug), "debug panel");
Assert.Greater(movedPhase.xMin, phase.xMin);
AssertWithinScreen(movedOpponent, "opponent zones after hide");
AssertWithinScreen(movedPhase, "phase panel after hide");
} }
finally finally
{ {
@ -228,28 +220,28 @@ namespace Vanguard.Tests
var usedField = typeof(BattleView) var usedField = typeof(BattleView)
.GetField("_mulliganUsed", BindingFlags.Instance | BindingFlags.NonPublic)!; .GetField("_mulliganUsed", BindingFlags.Instance | BindingFlags.NonPublic)!;
var rebuild = typeof(BattleView) var rebuild = typeof(BattleView)
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!; .GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
viewField.SetValue(battle, match); viewField.SetValue(battle, match);
usedField.SetValue(battle, true); usedField.SetValue(battle, true);
rebuild.Invoke(battle, new object[] { match }); rebuild.Invoke(battle, new object[] { match });
Assert.IsFalse( Assert.IsFalse(
FindRect("PhasePanel").gameObject.activeSelf, FindRect("ConfirmationBar").gameObject.activeSelf,
"Phase panel must disappear after the mulligan exchange"); "Confirmation bar must disappear after the mulligan exchange");
Assert.IsFalse( Assert.IsFalse(
Resources.FindObjectsOfTypeAll<GameObject>() Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Phase_FinishMulliganIntent"), .Any(go => go.name == "Intent_FinishMulliganIntent"),
"Finish mulligan button must not remain after the exchange"); "Finish mulligan button must not remain after the exchange");
usedField.SetValue(battle, false); usedField.SetValue(battle, false);
rebuild.Invoke(battle, new object[] { match }); rebuild.Invoke(battle, new object[] { match });
Assert.IsTrue( Assert.IsTrue(
FindRect("PhasePanel").gameObject.activeSelf, FindRect("ConfirmationBar").gameObject.activeSelf,
"Finish mulligan button must be available before the exchange"); "Finish mulligan button must be available before the exchange");
Assert.IsTrue( Assert.IsTrue(
Resources.FindObjectsOfTypeAll<GameObject>() Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Phase_FinishMulliganIntent")); .Any(go => go.name == "Intent_FinishMulliganIntent"));
} }
finally finally
{ {
@ -259,7 +251,7 @@ namespace Vanguard.Tests
} }
[Test] [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(); var config = new AppConfig();
config.Load(); config.Load();
@ -290,13 +282,13 @@ namespace Vanguard.Tests
var viewField = typeof(BattleView) var viewField = typeof(BattleView)
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!; .GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!;
var rebuild = typeof(BattleView) var rebuild = typeof(BattleView)
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!; .GetMethod("RebuildIntents", BindingFlags.Instance | BindingFlags.NonPublic)!;
viewField.SetValue(battle, match); viewField.SetValue(battle, match);
rebuild.Invoke(battle, new object[] { match }); rebuild.Invoke(battle, new object[] { match });
Assert.IsTrue( Assert.IsTrue(
FindRect("PhasePanel").gameObject.activeSelf, FindRect("ConfirmationBar").gameObject.activeSelf,
"Guard controls must be in the action panel"); "Guard controls must be in the confirmation bar");
var guardButton = Resources.FindObjectsOfTypeAll<Button>() var guardButton = Resources.FindObjectsOfTypeAll<Button>()
.FirstOrDefault(button => button.name == "Phase_Guard"); .FirstOrDefault(button => button.name == "Phase_Guard");
Assert.IsNotNull(guardButton); Assert.IsNotNull(guardButton);
@ -311,10 +303,6 @@ namespace Vanguard.Tests
Resources.FindObjectsOfTypeAll<GameObject>() Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Phase_PassGuardIntent"), .Any(go => go.name == "Phase_PassGuardIntent"),
"Pass guard must be the same single button"); "Pass guard must be the same single button");
Assert.AreEqual(
"ДЕЙСТВИЯ",
Resources.FindObjectsOfTypeAll<Text>()
.First(text => text.name == "PhaseTitle").text);
} }
finally finally
{ {
@ -762,17 +750,17 @@ namespace Vanguard.Tests
&& card.Ref.InstanceId == 7)); && card.Ref.InstanceId == 7));
var selfZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>() var selfZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "ZoneBlocksSelf"); .FirstOrDefault(go => go.name == "PlayerStatsSelf");
var opponentZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>() var opponentZoneRoot = Resources.FindObjectsOfTypeAll<GameObject>()
.FirstOrDefault(go => go.name == "ZoneBlocksOpponent"); .FirstOrDefault(go => go.name == "PlayerStatsOpponent");
Assert.IsNotNull(selfZoneRoot); Assert.IsNotNull(selfZoneRoot);
Assert.IsNotNull(opponentZoneRoot); Assert.IsNotNull(opponentZoneRoot);
var selfLabels = selfZoneRoot.GetComponentsInChildren<Text>(); var selfLabels = selfZoneRoot.GetComponentsInChildren<Text>();
var opponentLabels = opponentZoneRoot.GetComponentsInChildren<Text>(); var opponentLabels = opponentZoneRoot.GetComponentsInChildren<Text>();
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Рука 1"))); Assert.IsTrue(selfLabels.Any(text => text.text == "Рука"));
Assert.IsTrue(selfLabels.Any(text => text.text.Contains("Душа 1"))); Assert.IsTrue(selfLabels.Any(text => text.text.StartsWith("Душа")));
Assert.IsTrue(opponentLabels.Any(text => text.text.Contains("Рука 0"))); Assert.IsTrue(opponentLabels.Any(text => text.text == "Рука"));
Cleanup(); Cleanup();
} }

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
using Vanguard.Battle; using Vanguard.Battle;
@ -75,7 +76,7 @@ namespace Vanguard.Tests
&& card.Ref.Slot == 3); && card.Ref.Slot == 3);
Assert.IsTrue(rested.IsRested); Assert.IsTrue(rested.IsRested);
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f); 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 => var hiddenDamage = cards.Single(card =>
card.Ref.Zone == BoardZone.Damage card.Ref.Zone == BoardZone.Damage
@ -186,7 +187,611 @@ namespace Vanguard.Tests
} }
[Test] [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 config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config); var board = GameBoard3D.Create(null, config);
@ -226,11 +831,11 @@ namespace Vanguard.Tests
var layout = new PlaymatLayout(config); var layout = new PlaymatLayout(config);
Assert.AreEqual( Assert.AreEqual(
90f, -90f,
Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw), Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw),
0.1f); 0.1f);
Assert.AreEqual( Assert.AreEqual(
-90f, 90f,
Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw), Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw),
0.1f); 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] [Test]
public void CardMoveAnimator_Coalesces_Requests_For_Same_Card() public void CardMoveAnimator_Coalesces_Requests_For_Same_Card()
{ {

View File

@ -38,6 +38,15 @@ GraphicsSettings:
- {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3} - {fileID: 4800000, guid: 650dd9526735d5b46b79224bc6e94025, type: 3}
m_PreloadedShaders: [] m_PreloadedShaders: []
m_PreloadShadersBatchTimeLimit: -1 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_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
m_CustomRenderPipeline: {fileID: 0} m_CustomRenderPipeline: {fileID: 0}
m_TransparencySortMode: 0 m_TransparencySortMode: 0
@ -49,6 +58,7 @@ GraphicsSettings:
m_FogStripping: 0 m_FogStripping: 0
m_InstancingStripping: 0 m_InstancingStripping: 0
m_BrgStripping: 0 m_BrgStripping: 0
m_DefaultLightBaker: 0
m_LightmapKeepPlain: 1 m_LightmapKeepPlain: 1
m_LightmapKeepDirCombined: 1 m_LightmapKeepDirCombined: 1
m_LightmapKeepDynamicPlain: 1 m_LightmapKeepDynamicPlain: 1
@ -61,6 +71,11 @@ GraphicsSettings:
m_AlbedoSwatchInfos: [] m_AlbedoSwatchInfos: []
m_RenderPipelineGlobalSettingsMap: m_RenderPipelineGlobalSettingsMap:
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2} UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
m_ShaderBuildSettings:
keywordDeclarationOverrides: []
numInternalDefines: 0
defines: []
compilerSettings: []
m_LightsUseLinearIntensity: 1 m_LightsUseLinearIntensity: 1
m_LightsUseColorTemperature: 1 m_LightsUseColorTemperature: 1
m_LogWhenShaderIsCompiled: 0 m_LogWhenShaderIsCompiled: 0

View File

@ -9,10 +9,17 @@ UnityConnectSettings:
m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events m_EventOldUrl: https://api.uca.cloud.unity3d.com/v1/events
m_EventUrl: https://cdp.cloud.unity3d.com/v1/events m_EventUrl: https://cdp.cloud.unity3d.com/v1/events
m_ConfigUrl: https://config.uca.cloud.unity3d.com m_ConfigUrl: https://config.uca.cloud.unity3d.com
m_DashboardUrl: https://dashboard.unity3d.com
m_TestInitMode: 0 m_TestInitMode: 0
CrashReportingSettings: InsightsSettings:
m_EventUrl: https://perf-events.cloud.unity3d.com 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 m_Enabled: 0
CrashReportingSettings:
serializedVersion: 2
m_EventUrl: https://perf-events.cloud.unity3d.com
m_EnableCloudDiagnosticsReporting: 0
m_LogBufferSize: 10 m_LogBufferSize: 10
m_CaptureEditorExceptions: 1 m_CaptureEditorExceptions: 1
UnityPurchasingSettings: UnityPurchasingSettings:
@ -22,6 +29,7 @@ UnityConnectSettings:
m_Enabled: 0 m_Enabled: 0
m_TestMode: 0 m_TestMode: 0
m_InitializeOnStartup: 1 m_InitializeOnStartup: 1
m_PackageRequiringCoreStatsPresent: 0
UnityAdsSettings: UnityAdsSettings:
m_Enabled: 0 m_Enabled: 0
m_InitializeOnStartup: 1 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 }) .Select(a => new TriggeredAbility { Definition = a, Context = context })
.ToList(); .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) if (triggered.Count == 0)
return; return;
@ -131,6 +143,75 @@ public sealed class GameEngine
yield return granted.Definition; 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) private void RaiseBatch(List<TriggeredAbility> triggered)
{ {
if (triggered.Count == 0) if (triggered.Count == 0)
@ -1452,18 +1533,24 @@ public sealed class GameEngine
if (State.PendingTrigger is { } pending) 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)) 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)) foreach (var effectSlot in UnitSlots(pending.Player))
{
if (pending.Trigger == TriggerType.Stand && effectSlot < 0 && hasRearGuard)
continue;
commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot)); commands.Add(new ResolveTriggerCommand(powerSlot, effectSlot));
}
else if (pending.Trigger == TriggerType.Stand)
{
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));
} }
} }
else else
@ -1575,7 +1662,7 @@ public sealed class GameEngine
targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0 targets.AddRange(UnitSlots(1 - player).Where(slot => slot >= 0
&& (slot is 3 or 4 && (slot is 3 or 4
|| (canSnipe && SameColumn(attackerSlot, slot))))); || (canSnipe && SameColumnAcrossPlayers(attackerSlot, slot)))));
foreach (var target in targets) foreach (var target in targets)
commands.Add(new AttackCommand(attackerSlot, target)); commands.Add(new AttackCommand(attackerSlot, target));
@ -1614,16 +1701,9 @@ public sealed class GameEngine
if (def.IsSentinel) if (def.IsSentinel)
{ {
// Оплата Sentinel теперь идёт отдельной автоматической способностью
// после размещения в круг защиты.
commands.Add(new GuardCommand(printingId)); 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) 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) public void Start(int? firstPlayer = null)
@ -1788,7 +1879,8 @@ public sealed class GameEngine
var card = p.Deck[index]; var card = p.Deck[index];
p.Deck.RemoveAt(index); p.Deck.RemoveAt(index);
card.FaceUp = true; // Стартовый Авангард лежит закрытым до конца муллигана.
card.FaceUp = false;
p.Vanguard = card; p.Vanguard = card;
} }
@ -1891,6 +1983,13 @@ public sealed class GameEngine
return CommandResult.Ok(); return CommandResult.Ok();
} }
// Оба муллигана завершены: открываем стартовые Авангарды.
for (var i = 0; i < 2; i++)
{
if (State.Players[i].Vanguard is { } vanguard)
vanguard.FaceUp = true;
}
State.ActivePlayer = State.FirstPlayer; State.ActivePlayer = State.FirstPlayer;
StartTurn(); StartTurn();
return CommandResult.Ok(); 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) (fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2)
or (-1, 1) or (1, -1); 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() private CommandResult EnterBattle()
{ {
if (State.Phase != Phase.Main) if (State.Phase != Phase.Main)
@ -2062,7 +2169,7 @@ public sealed class GameEngine
if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow) if (!State.Catalog.CardOf(attacker.PrintingId).CanSnipeBackRow)
return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности."); return CommandResult.Reject("Арьергард в заднем ряду нельзя атаковать без способности.");
if (!SameColumn(attackerSlot, targetSlot)) if (!SameColumnAcrossPlayers(attackerSlot, targetSlot))
return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки."); return CommandResult.Reject("Атаковать задний ряд можно только из своей колонки.");
} }
@ -2187,17 +2294,8 @@ public sealed class GameEngine
if (!def.IsSentinel && discardPrintingId is not null) if (!def.IsSentinel && discardPrintingId is not null)
return CommandResult.Reject("Эта карта не требует оплаты стоимости."); return CommandResult.Reject("Эта карта не требует оплаты стоимости.");
if (def.IsSentinel && discardPrintingId is not null) // Sentinel сам по себе даёт ноль щита: оплата и отмена атаки разрешается
{ // отдельной автоматической способностью после размещения в круг защиты.
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 сам по себе даёт ноль щита: либо оплата и отмена атаки, либо ничего
var shield = def.IsSentinel ? 0 : def.Shield; var shield = def.IsSentinel ? 0 : def.Shield;
defender.Hand.RemoveAt(index); defender.Hand.RemoveAt(index);
@ -2207,17 +2305,6 @@ public sealed class GameEngine
battle.GuardShield += shield; battle.GuardShield += shield;
_events.Add(new GuardAddedEvent(defenderIndex, card.PrintingId, 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); TriggerFromCard(defenderIndex, card, AbilityTrigger.OnGuard, null);
// способность на размещение в (GC) может встать на выбор игрока: // способность на размещение в (GC) может встать на выбор игрока:
@ -2478,17 +2565,24 @@ public sealed class GameEngine
return CommandResult.Reject("Нет цели для +5000 силы."); return CommandResult.Reject("Нет цели для +5000 силы.");
} }
if (pending.Trigger is TriggerType.Critical or TriggerType.Stand) if (pending.Trigger == TriggerType.Critical)
{ {
var effectUnit = State.GetUnit(player, command.EffectTarget); if (State.GetUnit(player, command.EffectTarget) is null)
if (effectUnit is null) return CommandResult.Reject("Нет цели для Крит +1.");
return CommandResult.Reject("Нет цели для второго эффекта."); }
else if (pending.Trigger == TriggerType.Stand)
if (pending.Trigger == TriggerType.Stand && command.EffectTarget < 0) {
if (command.EffectTarget < 0)
{ {
// если Арьергардов нет, поднимать нечего — второй эффект не применяется // Если есть повёрнутый RC, игрок обязан выбрать его.
if (State.Players[player].RearGuards.Any(u => u is not null)) if (RestedRearGuardSlots(player).Any())
return CommandResult.Reject("Поднять можно только карту в кругу Арьергарда."); return CommandResult.Reject("Выберите повёрнутую карту в RC для подъёма.");
}
else
{
var effectUnit = State.GetUnit(player, command.EffectTarget);
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 Vanguard = player.Vanguard is null
? null ? null
: ProjectCard(engine, player.Vanguard, forceFaceUp: true), : ProjectCard(engine, player.Vanguard),
RearGuards = player.RearGuards RearGuards = player.RearGuards
.Select(card => card is null .Select(card => card is null
? null ? null

View File

@ -153,6 +153,24 @@ public class GameEngineTests
engine.State.Catalog.CardOf(engine.State.Players[0].Vanguard!.PrintingId).Grade); 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] [Fact]
public void Both_Players_Mulligan_Once_Then_First_Player_Starts() public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
{ {
@ -428,6 +446,22 @@ public class GameEngineTests
Assert.False(rearGuard.Rested); 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] [Fact]
public void Damage_Trigger_Also_Asks_For_Targets_And_Applies_Effect() public void Damage_Trigger_Also_Asks_For_Targets_And_Applies_Effect()
{ {
@ -596,7 +630,19 @@ public class GameEngineTests
Give(engine, 1, Marron); Give(engine, 1, Marron);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); 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.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Empty(engine.State.Players[1].Damage); Assert.Empty(engine.State.Players[1].Damage);
@ -611,9 +657,31 @@ public class GameEngineTests
SetVanguard(engine, 1, Marron); SetVanguard(engine, 1, Marron);
StackDeck(engine.State.Players[0], Gallatin, Gallatin); StackDeck(engine.State.Players[0], Gallatin, Gallatin);
Give(engine, 1, Iseult); 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 AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new GuardCommand(Iseult)).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.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Single(engine.State.Players[1].Damage); Assert.Single(engine.State.Players[1].Damage);
@ -621,14 +689,29 @@ public class GameEngineTests
} }
[Fact] [Fact]
public void Perfect_Guard_Rejects_Wrong_Clan_Cost() public void Perfect_Guard_Offers_Only_Same_Clan_Cards()
{ {
var engine = ReadyToAttack(); var engine = ReadyToAttack();
SetVanguard(engine, 1, Marron);
Give(engine, 1, Iseult); Give(engine, 1, Iseult);
Give(engine, 1, Marron);
Give(engine, 1, Nehalem); Give(engine, 1, Nehalem);
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted); 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] [Fact]
@ -1996,15 +2079,48 @@ public class GameEngineTests
engine.State.Players[0].RearGuards[3] = tejas; engine.State.Players[0].RearGuards[3] = tejas;
tejas.Rested = false; 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]; var defender = engine.State.Players[1];
defender.RearGuards[0] = MakeCard(Barcgal); 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.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Null(defender.RearGuards[0]); 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] [Fact]
public void Tejas_On_Vanguard_Attacks_Back_Row_Center() public void Tejas_On_Vanguard_Attacks_Back_Row_Center()
{ {