diff --git a/client/Assets/Settings/PC_RPAsset.asset b/client/Assets/Settings/PC_RPAsset.asset
index 73f9aa7..c00222b 100644
--- a/client/Assets/Settings/PC_RPAsset.asset
+++ b/client/Assets/Settings/PC_RPAsset.asset
@@ -84,6 +84,7 @@ MonoBehaviour:
m_SupportScreenSpaceLensFlare: 1
m_GPUResidentDrawerMode: 0
m_SmallMeshScreenPercentage: 0
+ m_ShadowSmallMeshScreenPercentages: {x: 0, y: 0, z: 0, w: 0}
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
m_ShadowType: 1
m_LocalShadowsSupported: 0
@@ -100,18 +101,18 @@ MonoBehaviour:
m_Keys: []
m_Values:
m_PrefilteringModeMainLightShadows: 3
- m_PrefilteringModeAdditionalLight: 4
- m_PrefilteringModeAdditionalLightShadows: 0
+ m_PrefilteringModeAdditionalLight: 0
+ m_PrefilteringModeAdditionalLightShadows: 2
m_PrefilterXRKeywords: 1
- m_PrefilteringModeForwardPlus: 1
+ m_PrefilteringModeForwardPlus: 2
m_PrefilteringModeDeferredRendering: 0
- m_PrefilteringModeScreenSpaceOcclusion: 1
- m_PrefilteringModeScreenSpaceReflection: 1
+ m_PrefilteringModeScreenSpaceOcclusion: 2
+ m_PrefilteringModeScreenSpaceReflection: 0
m_PrefilterWriteSmoothness: 1
m_PrefilterDebugKeywords: 1
- m_PrefilterWriteRenderingLayers: 0
+ m_PrefilterWriteRenderingLayers: 1
m_PrefilterHDROutput: 1
- m_PrefilterAlphaOutput: 0
+ m_PrefilterAlphaOutput: 1
m_PrefilterSSAODepthNormals: 0
m_PrefilterSSAOSourceDepthLow: 1
m_PrefilterSSAOSourceDepthMedium: 1
@@ -123,20 +124,23 @@ MonoBehaviour:
m_PrefilterSSAOSampleCountHigh: 1
m_PrefilterDBufferMRT1: 1
m_PrefilterDBufferMRT2: 1
- m_PrefilterDBufferMRT3: 0
- m_PrefilterSoftShadowsQualityLow: 0
- m_PrefilterSoftShadowsQualityMedium: 0
- m_PrefilterSoftShadowsQualityHigh: 0
+ m_PrefilterDBufferMRT3: 1
+ m_PrefilterSoftShadowsQualityLow: 1
+ m_PrefilterSoftShadowsQualityMedium: 1
+ m_PrefilterSoftShadowsQualityHigh: 1
m_PrefilterSoftShadows: 0
m_PrefilterScreenCoord: 1
- m_PrefilterScreenSpaceIrradiance: 0
+ m_PrefilterScreenSpaceIrradiance: 1
m_PrefilterNativeRenderPass: 1
m_PrefilterUseLegacyLightmaps: 0
- m_PrefilterBicubicLightmapSampling: 0
+ m_PrefilterBicubicLightmapSampling: 1
m_PrefilterReflectionProbeRotation: 0
m_PrefilterReflectionProbeBlending: 0
m_PrefilterReflectionProbeBoxProjection: 0
m_PrefilterReflectionProbeAtlas: 0
+ m_PrefilterPointSamplingUpsampling: 1
+ m_Light2DPrefilteringMode: 0
+ m_Light2DKeptVariantCombos: []
m_ShaderVariantLogLevel: 0
m_ShadowCascades: 0
m_Textures:
diff --git a/client/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset b/client/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset
index 3cc7fb3..b106c70 100644
--- a/client/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset
+++ b/client/Assets/Settings/UniversalRenderPipelineGlobalSettings.asset
@@ -72,7 +72,22 @@ MonoBehaviour:
- rid: 6588237625532874761
- rid: 6588237625532874762
m_RuntimeSettings:
- m_List: []
+ m_List:
+ - rid: 6852985685364965378
+ - rid: 6852985685364965380
+ - rid: 6852985685364965381
+ - rid: 6852985685364965384
+ - rid: 6852985685364965392
+ - rid: 6852985685364965394
+ - rid: 8712630790384254976
+ - rid: 4284062438471237637
+ - rid: 5747374308340793346
+ - rid: 5747374308340793347
+ - rid: 5747374308340793348
+ - rid: 6588237625532874753
+ - rid: 6588237625532874756
+ - rid: 6588237625532874759
+ - rid: 6588237625532874761
m_AssetVersion: 11
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames:
diff --git a/client/Assets/Vanguard/Resources/AnimationConfig.asset b/client/Assets/Vanguard/Resources/AnimationConfig.asset
index ab72854..9e5e6ca 100644
--- a/client/Assets/Vanguard/Resources/AnimationConfig.asset
+++ b/client/Assets/Vanguard/Resources/AnimationConfig.asset
@@ -28,15 +28,25 @@ MonoBehaviour:
intercept: 1.9
perfectGuard: 2
inputLockTail: 0.15
+ eventPause: 1.5
+ postDrivePause: 1.5
+ postTriggerPause: 1.5
+ postDamagePause: 1.5
arrowShowDuration: 0.25
arrowHideDuration: 0.2
- moveDuration: 0.5
- importantMoveDuration: 0.9
- moveDelayBetweenCards: 0.18
+ moveDuration: 0.8
+ importantMoveDuration: 1.3
+ moveDelayBetweenCards: 0.22
arcHeight: 0.45
- cameraZoomDuration: 0.35
- triggerRevealHold: 1.2
- cameraReturnDuration: 0.4
+ introOverheadHold: 1.5
+ introCameraMoveDuration: 1
+ introMainHold: 1.5
+ introDrawDuration: 0.6
+ cameraZoomDuration: 0.5
+ vanguardRevealDuration: 0.8
+ triggerRevealDuration: 0.35
+ triggerRevealHold: 0.75
+ cameraReturnDuration: 0.5
cameraRezoomDuration: 0.3
growDuration: 0.35
dashScrollSpeed: 1.5
diff --git a/client/Assets/Vanguard/Resources/Overlays.meta b/client/Assets/Vanguard/Resources/Overlays.meta
new file mode 100644
index 0000000..4360774
--- /dev/null
+++ b/client/Assets/Vanguard/Resources/Overlays.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6801c3b9bef9c1644a4c0af035547bd0
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs
index 981d25f..7af5d8d 100644
--- a/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs
+++ b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs
@@ -18,15 +18,23 @@ namespace Vanguard.Battle
public float retireDuration = 0.8f;
[Header("3D UX: движение")]
- public float moveDuration = 0.5f;
- public float importantMoveDuration = 0.9f;
- public float moveDelayBetweenCards = 0.18f;
+ public float moveDuration = 0.8f;
+ public float importantMoveDuration = 1.3f;
+ public float moveDelayBetweenCards = 0.22f;
public float arcHeight = 0.45f;
+ [Header("Старт боя")]
+ public float introOverheadHold = 1.5f;
+ public float introCameraMoveDuration = 1f;
+ public float introMainHold = 1.5f;
+ public float introDrawDuration = 0.6f;
+
[Header("3D UX: камера")]
- public float cameraZoomDuration = 0.35f;
- public float triggerRevealHold = 1.2f;
- public float cameraReturnDuration = 0.4f;
+ public float cameraZoomDuration = 0.5f;
+ public float vanguardRevealDuration = 0.8f;
+ public float triggerRevealDuration = 0.35f;
+ public float triggerRevealHold = 0.75f;
+ public float cameraReturnDuration = 0.5f;
public float cameraRezoomDuration = 0.3f;
[Header("3D UX: стрелки")]
@@ -50,6 +58,12 @@ namespace Vanguard.Battle
[Header("Ввод")]
public float inputLockTail = 0.15f;
+ [Header("Боевые паузы")]
+ public float eventPause = 1.5f;
+ public float postDrivePause = 1.5f;
+ public float postTriggerPause = 1.5f;
+ public float postDamagePause = 1.5f;
+
[Header("Стрелка атаки")]
public float arrowShowDuration = 0.25f;
public float arrowHideDuration = 0.2f;
@@ -89,7 +103,13 @@ namespace Vanguard.Battle
importantMoveDuration = Mathf.Max(0.05f, importantMoveDuration);
moveDelayBetweenCards = Mathf.Max(0f, moveDelayBetweenCards);
arcHeight = Mathf.Max(0f, arcHeight);
+ introOverheadHold = Mathf.Max(0f, introOverheadHold);
+ introCameraMoveDuration = Mathf.Max(0.05f, introCameraMoveDuration);
+ introMainHold = Mathf.Max(0f, introMainHold);
+ introDrawDuration = Mathf.Max(0.05f, introDrawDuration);
cameraZoomDuration = Mathf.Max(0.05f, cameraZoomDuration);
+ vanguardRevealDuration = Mathf.Max(0.05f, vanguardRevealDuration);
+ triggerRevealDuration = Mathf.Max(0.05f, triggerRevealDuration);
triggerRevealHold = Mathf.Max(0f, triggerRevealHold);
cameraReturnDuration = Mathf.Max(0.05f, cameraReturnDuration);
cameraRezoomDuration = Mathf.Max(0.05f, cameraRezoomDuration);
@@ -108,6 +128,10 @@ namespace Vanguard.Battle
intercept = Mathf.Max(0.05f, intercept);
perfectGuard = Mathf.Max(0.05f, perfectGuard);
inputLockTail = Mathf.Max(0f, inputLockTail);
+ eventPause = Mathf.Max(0f, eventPause);
+ postDrivePause = Mathf.Max(0f, postDrivePause);
+ postTriggerPause = Mathf.Max(0f, postTriggerPause);
+ postDamagePause = Mathf.Max(0f, postDamagePause);
arrowShowDuration = Mathf.Max(0.05f, arrowShowDuration);
arrowHideDuration = Mathf.Max(0.05f, arrowHideDuration);
}
diff --git a/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs b/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs
index 6038c22..8da11f4 100644
--- a/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs
+++ b/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs
@@ -68,6 +68,41 @@ namespace Vanguard.Battle
duration);
}
+ public void SetImmediate(Vector3 position, Vector3 target, float fieldOfView)
+ {
+ if (_camera == null)
+ return;
+
+ _moving = false;
+ var rotation = Quaternion.LookRotation(target - position, Vector3.up);
+ Apply(new Pose
+ {
+ Position = position,
+ Rotation = rotation,
+ FieldOfView = fieldOfView,
+ });
+ }
+
+ public void MoveTo(
+ Vector3 position,
+ Vector3 target,
+ float fieldOfView,
+ float duration)
+ {
+ if (_camera == null)
+ return;
+
+ var rotation = Quaternion.LookRotation(target - position, Vector3.up);
+ AnimateTo(
+ new Pose
+ {
+ Position = position,
+ Rotation = rotation,
+ FieldOfView = fieldOfView,
+ },
+ duration);
+ }
+
public void ReturnToOrigin(float duration)
{
if (!_hasOrigin)
diff --git a/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs b/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs
index 5109bd9..c06c660 100644
--- a/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs
+++ b/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs
@@ -1,3 +1,4 @@
+using System;
using UnityEngine;
using Vanguard.Battle;
@@ -13,6 +14,7 @@ namespace Vanguard.Battle
{
Idle,
Zooming,
+ Revealing,
Holding,
Returning,
}
@@ -22,6 +24,9 @@ namespace Vanguard.Battle
private Stage _stage;
private float _deadline;
+ private float _zoomDeadline;
+ private float _revealDeadline;
+ private float _resolveDeadline;
private bool _chainActive;
private bool _restoreRequested;
private bool _lockHeld;
@@ -36,6 +41,18 @@ namespace Vanguard.Battle
public bool IsBusy => _stage != Stage.Idle;
+ ///
+ /// true — камера ушла на trigger-зону, руку нужно скрыть;
+ /// false — камера вернулась, руку можно показать.
+ ///
+ public event Action FocusChanged;
+
+ /// Камера закончила зум и стоит на trigger-зоне.
+ public event Action ZoomCompleted;
+
+ /// Карта раскрыта и выдержана пауза — можно резолвить триггер.
+ public event Action ResolveReady;
+
public void ShowTrigger(
AnimationConfig config,
Vector3 worldPoint)
@@ -43,6 +60,8 @@ namespace Vanguard.Battle
if (_camera == null)
return;
+ var wasIdle = _stage == Stage.Idle;
+
if (!_chainActive)
{
_camera.BeginChain();
@@ -54,13 +73,21 @@ namespace Vanguard.Battle
var zoom = config != null
? config.cameraZoomDuration
: 0.35f;
- var hold = config != null
+ var reveal = config != null
+ ? config.triggerRevealDuration
+ : 0.35f;
+ var pause = config != null
? config.triggerRevealHold
- : 1.2f;
+ : 0.75f;
_camera.ZoomTo(worldPoint, zoom);
_stage = Stage.Zooming;
- _deadline = Time.unscaledTime + zoom + hold;
+ _zoomDeadline = Time.unscaledTime + zoom;
+ _revealDeadline = _zoomDeadline + reveal;
+ _resolveDeadline = _revealDeadline + pause;
+
+ if (wasIdle)
+ OnFocusChanged(true);
}
public void ReturnForChoice(AnimationConfig config)
@@ -99,15 +126,30 @@ namespace Vanguard.Battle
return;
if (_stage == Stage.Zooming
- && Time.unscaledTime >= _deadline)
+ && Time.unscaledTime >= _zoomDeadline)
{
+ _stage = Stage.Revealing;
+ ZoomCompleted?.Invoke();
+ }
+
+ if (_stage == Stage.Revealing
+ && Time.unscaledTime >= _revealDeadline)
+ {
+ _stage = Stage.Holding;
+ }
+
+ if (_stage == Stage.Holding
+ && Time.unscaledTime >= _resolveDeadline)
+ {
+ ResolveReady?.Invoke();
+
var duration = config != null
? config.cameraReturnDuration
: 0.4f;
+ _restoreRequested = true;
_camera.ReturnToOrigin(duration);
_stage = Stage.Returning;
_deadline = Time.unscaledTime + duration;
- return;
}
if (_stage == Stage.Returning
@@ -121,6 +163,8 @@ namespace Vanguard.Battle
_chainActive = false;
_camera.Restore();
}
+
+ OnFocusChanged(false);
}
}
@@ -144,10 +188,18 @@ namespace Vanguard.Battle
public void Cancel()
{
+ var wasIdle = _stage == Stage.Idle;
_stage = Stage.Idle;
_chainActive = false;
Release();
_camera?.Restore();
+ if (!wasIdle)
+ OnFocusChanged(false);
+ }
+
+ private void OnFocusChanged(bool focused)
+ {
+ FocusChanged?.Invoke(focused);
}
}
}
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs
index a1461b6..b84b680 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using UnityEngine;
using Vanguard.Battle;
@@ -19,12 +20,17 @@ namespace Vanguard.Board3D
public Vector3 FromScale;
public float Duration;
public float ArcHeight;
+ public bool Sequential;
+ public Action OnCompleted;
+ public Action OnStart;
+ public bool FromCurrent;
}
private sealed class ActiveMove
{
public Card3D Card;
public float Remaining;
+ public Action OnCompleted;
}
private readonly Queue _queue = new Queue();
@@ -86,7 +92,11 @@ namespace Vanguard.Board3D
Quaternion fromRotation,
Vector3 fromScale,
float duration,
- float arcHeight)
+ float arcHeight,
+ bool sequential = false,
+ Action onCompleted = null,
+ Action onStart = null,
+ bool fromCurrent = false)
{
if (card == null || card.IsDragging)
return;
@@ -104,9 +114,47 @@ namespace Vanguard.Board3D
FromScale = fromScale,
Duration = duration,
ArcHeight = arcHeight,
+ Sequential = sequential,
+ OnCompleted = onCompleted,
+ OnStart = onStart,
+ FromCurrent = fromCurrent,
});
}
+ ///
+ /// Запускает перемещение сразу, без очереди и stagger. Нужен для
+ /// парных доборов, где две карты должны лететь одновременно.
+ ///
+ public void EnqueueNow(
+ Card3D card,
+ Vector3 fromPosition,
+ Quaternion fromRotation,
+ Vector3 fromScale,
+ float duration,
+ float arcHeight)
+ {
+ if (card == null || card.IsDragging)
+ return;
+
+ CancelCard(card);
+ EnsureLock();
+ card.StartArcMove(
+ fromPosition,
+ fromRotation,
+ fromScale,
+ duration,
+ arcHeight);
+
+ if (card.IsMoving)
+ {
+ _active.Add(new ActiveMove
+ {
+ Card = card,
+ Remaining = Mathf.Max(0.1f, duration) + 0.05f,
+ });
+ }
+ }
+
public void Tick(float deltaTime)
{
if (_active.Count > 0)
@@ -130,6 +178,7 @@ namespace Vanguard.Board3D
if (!card.IsMoving)
{
_active.RemoveAt(i);
+ active.OnCompleted?.Invoke(card);
continue;
}
@@ -139,6 +188,7 @@ namespace Vanguard.Board3D
{
card.StopMove(snap: true);
_active.RemoveAt(i);
+ active.OnCompleted?.Invoke(card);
}
}
}
@@ -156,6 +206,12 @@ namespace Vanguard.Board3D
public void CancelAll(bool snapCards = false)
{
+ foreach (var request in _queue)
+ {
+ if (request?.Card != null)
+ request.Card.CancelHold();
+ }
+
_queue.Clear();
foreach (var active in _active)
{
@@ -173,17 +229,27 @@ namespace Vanguard.Board3D
if (card == null)
return;
+ var wasQueued = false;
var remaining = new Queue();
while (_queue.Count > 0)
{
var request = _queue.Dequeue();
if (request.Card != card)
+ {
remaining.Enqueue(request);
+ }
+ else
+ {
+ wasQueued = true;
+ }
}
while (remaining.Count > 0)
_queue.Enqueue(remaining.Dequeue());
+ if (wasQueued)
+ card.CancelHold();
+
for (var i = _active.Count - 1; i >= 0; i--)
{
if (_active[i]?.Card != card)
@@ -202,6 +268,14 @@ namespace Vanguard.Board3D
if (_queue.Count == 0)
return;
+ // Последовательные запросы (добор, возврат в колоду) идут строго
+ // по одной карте: следующая стартует только после предыдущей.
+ if (_queue.Peek().Sequential && _active.Count > 0)
+ {
+ _delay = 0f;
+ return;
+ }
+
var request = _queue.Dequeue();
var card = request.Card;
if (card == null || card.IsDragging)
@@ -210,10 +284,18 @@ namespace Vanguard.Board3D
return;
}
+ request.OnStart?.Invoke(card);
+
card.StartArcMove(
- request.FromPosition,
- request.FromRotation,
- request.FromScale,
+ request.FromCurrent
+ ? card.transform.position
+ : request.FromPosition,
+ request.FromCurrent
+ ? card.transform.rotation
+ : request.FromRotation,
+ request.FromCurrent
+ ? card.transform.localScale
+ : request.FromScale,
request.Duration,
request.ArcHeight);
@@ -223,8 +305,13 @@ namespace Vanguard.Board3D
{
Card = card,
Remaining = Mathf.Max(0.1f, request.Duration) + 0.05f,
+ OnCompleted = request.OnCompleted,
});
}
+ else
+ {
+ request.OnCompleted?.Invoke(card);
+ }
_delay = Mathf.Max(0.01f, _config != null
? _config.moveDelayBetweenCards
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset
index 7f43179..e444b5f 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset
+++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset
@@ -30,27 +30,34 @@ MonoBehaviour:
dragThresholdPixels: 7
smoothMoveSpeed: 16
smoothRotateSpeed: 14
- handHoverLift: 0.08
+ handHoverLift: 0.1
handCenterZ: -5.3
handHeight: 1.12
handTilt: 58
- handSpacing: 0.42
+ handSpacing: 0.28
handMaxWidth: 7.4
handArc: 0.035
- handViewportX: 0.44
+ handViewportX: 0.5
handViewportY: 0.1
handViewDistance: 4
- handViewMaxWidth: 1.4
+ handViewMaxWidth: 2.52
handViewScale: 0.5
handViewRoll: 3.2
handViewArc: 0.02
opponentHandEnabled: 1
opponentHandZ: 4.3
opponentHandHeight: 0.75
- opponentHandSpacing: 0.42
- opponentHandMaxWidth: 2.4
- opponentHandScale: 0.8
- opponentHandRoll: 2.5
+ opponentHandSpacing: 0.28
+ opponentHandMaxWidth: 3.78
+ opponentHandScale: 0.9
+ opponentHandRoll: 5
+ opponentHandArc: 0.08
+ opponentHandViewportY: 0.76
+ opponentHandViewDistance: 6
+ opponentHandViewScale: 0.5
+ opponentHandViewMaxWidth: 2.4
+ opponentHandViewRoll: 3.2
+ opponentHandViewArc: 0.08
guardianRowGap: 0.55
guardianSpacing: 0.62
guardianMaxWidth: 3.6
@@ -60,6 +67,9 @@ MonoBehaviour:
cameraPosition: {x: 0, y: 4.2, z: -8}
cameraTarget: {x: 0, y: 0, z: 0}
cameraFov: 46
+ introCameraPosition: {x: 0, y: 7.5, z: -5.5}
+ introCameraTarget: {x: 0, y: 0.4, z: 2}
+ introCameraFov: 55
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}
matOpponentColor: {r: 0.13, g: 0.065, b: 0.075, a: 1}
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs
index 21259f1..2fbb38d 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs
@@ -41,24 +41,25 @@ namespace Vanguard.Board3D
public float dragThresholdPixels = 7f;
public float smoothMoveSpeed = 16f;
public float smoothRotateSpeed = 14f;
- public float handHoverLift = 0.08f;
+ [Tooltip("Подъём карты при наведении, доля её высоты.")]
+ [Range(0f, 0.5f)] public float handHoverLift = 0.1f;
[Header("Рука на столе (fallback без камеры)")]
public float handCenterZ = -5.3f;
public float handHeight = 1.12f;
[Range(0f, 85f)] public float handTilt = 58f;
- public float handSpacing = 0.42f;
+ public float handSpacing = 0.28f;
public float handMaxWidth = 7.4f;
public float handArc = 0.035f;
[Header("Рука в поле зрения")]
[Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")]
- [Range(0.05f, 0.95f)] public float handViewportX = 0.44f;
+ [Range(0.05f, 0.95f)] public float handViewportX = 0.5f;
[Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")]
[Range(0.05f, 0.45f)] public float handViewportY = 0.10f;
[Tooltip("Расстояние от камеры до веера руки, метры.")]
public float handViewDistance = 4.0f;
- public float handViewMaxWidth = 1.4f;
+ public float handViewMaxWidth = 2.52f;
public float handViewScale = 0.5f;
public float handViewRoll = 3.2f;
public float handViewArc = 0.02f;
@@ -67,10 +68,19 @@ namespace Vanguard.Board3D
public bool opponentHandEnabled = true;
public float opponentHandZ = 4.3f;
public float opponentHandHeight = 0.75f;
- public float opponentHandSpacing = 0.42f;
- public float opponentHandMaxWidth = 2.4f;
- public float opponentHandScale = 0.8f;
- public float opponentHandRoll = 2.5f;
+ public float opponentHandSpacing = 0.28f;
+ public float opponentHandMaxWidth = 3.78f;
+ public float opponentHandScale = 0.9f;
+ public float opponentHandRoll = 5f;
+ public float opponentHandArc = 0.08f;
+
+ [Header("Рука противника в поле зрения")]
+ [Range(0.5f, 0.95f)] public float opponentHandViewportY = 0.76f;
+ public float opponentHandViewDistance = 6f;
+ public float opponentHandViewScale = 0.5f;
+ public float opponentHandViewMaxWidth = 2.4f;
+ public float opponentHandViewRoll = 3.2f;
+ public float opponentHandViewArc = 0.08f;
[Header("Круг защиты")]
public float guardianRowGap = 0.55f;
@@ -80,6 +90,8 @@ namespace Vanguard.Board3D
[Header("Стопки")]
[Tooltip("Расстояние от центра стола до зоны урона.")]
public float damageX = 3.0f;
+ [Tooltip("Сдвиг следующей карты урона в долях ширины карты. 0.3 = новая карта перекрывает 30% предыдущей.")]
+ [Range(0.1f, 0.9f)] public float damageStackOffset = 0.3f;
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
public float pileX = 2.95f;
[Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")]
@@ -95,6 +107,12 @@ namespace Vanguard.Board3D
public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
public float cameraFov = 46f;
+ [Header("Стартовая камера")]
+ [Tooltip("Начальная позиция: над стороной игрока, лицом к столу.")]
+ public Vector3 introCameraPosition = new Vector3(0f, 7.5f, -5.5f);
+ public Vector3 introCameraTarget = new Vector3(0f, 0.4f, 2f);
+ public float introCameraFov = 55f;
+
[Header("Палитра")]
public Color tableColor = new Color(0.045f, 0.04f, 0.055f);
public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f);
@@ -156,6 +174,8 @@ namespace Vanguard.Board3D
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f);
handViewportX = Mathf.Clamp(handViewportX, 0.05f, 0.95f);
+ damageStackOffset = Mathf.Clamp(damageStackOffset, 0.1f, 0.9f);
+ introCameraFov = Mathf.Clamp(introCameraFov, 20f, 90f);
handViewDistance = Mathf.Max(1f, handViewDistance);
handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth);
handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f);
@@ -167,6 +187,7 @@ namespace Vanguard.Board3D
opponentHandMaxWidth = Mathf.Max(0.5f, opponentHandMaxWidth);
opponentHandScale = Mathf.Clamp(opponentHandScale, 0.3f, 1.5f);
opponentHandRoll = Mathf.Clamp(opponentHandRoll, 0f, 20f);
+ opponentHandArc = Mathf.Max(0f, opponentHandArc);
}
}
}
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs
index 9f7684a..4a5afea 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs
@@ -239,16 +239,22 @@ namespace Vanguard.Board3D
/// Текстура лица и акцентный цвет карты. Front может быть null.
public sealed class BoardArt
{
- public BoardArt(Texture2D front, Color accent, string displayName = null)
+ public BoardArt(
+ Texture2D front,
+ Color accent,
+ string displayName = null,
+ string trigger = null)
{
Front = front;
Accent = accent;
DisplayName = displayName;
+ Trigger = trigger;
}
public Texture2D Front { get; }
public Color Accent { get; }
public string DisplayName { get; }
+ public string Trigger { get; }
public static BoardArt Fallback(Color accent) => new BoardArt(null, accent);
}
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
index 6fdf510..fde6629 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
@@ -67,9 +67,16 @@ namespace Vanguard.Board3D
var zSign = ZSign(localSeat);
var sideSign = localSeat == 0 ? -1f : 1f;
var x = sideSign * _config.damageX;
+ // Карты лежат на боку: вертикальный шаг задаётся шириной карты.
+ // 0.3 — новая карта перекрывает 30% предыдущей.
+ var step = _config.cardWidth
+ * _config.cardScale
+ * _config.damageStackOffset;
var z = zSign * (_config.frontRowZ + 0.4f)
- + zSign * index * 0.4f;
- return new Vector3(x, _config.DamageCenterY, z);
+ + zSign * index * step;
+ // Небольшой подъём по Y: новая карта рисуется поверх старой.
+ var y = _config.DamageCenterY + (index * 0.0015f);
+ return new Vector3(x, y, z);
}
/// Колода и дроп сбоку от поля, у своего края.
@@ -152,9 +159,11 @@ namespace Vanguard.Board3D
var offset = index - (safeCount - 1) * 0.5f;
var x = offset * spacing;
- var z = _config.opponentHandZ + Mathf.Abs(offset) * 0.015f;
+ // Как и своя рука: веер по X/Y/roll, а по глубине каждая
+ // следующая карта ближе к камере и рисуется поверх предыдущей.
+ var z = _config.opponentHandZ - (index * 0.05f);
var y = _config.opponentHandHeight
- - (1f - Mathf.Abs(offset) / safeCount) * 0.02f;
+ + (1f - Mathf.Abs(offset) / safeCount) * _config.opponentHandArc;
var roll = -offset * _config.opponentHandRoll;
return new HandPose(new Vector3(x, y, z), roll);
}
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs
index 7c44c4c..eecd4a3 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs
@@ -45,13 +45,23 @@ namespace Vanguard.Board3D
new Vector3(halfW, 0f, halfH),
new Vector3(-halfW, 0f, halfH),
},
- uv = new[]
- {
- new Vector2(0f, 0f),
- new Vector2(1f, 0f),
- new Vector2(1f, 1f),
- new Vector2(0f, 1f),
- },
+ // Задняя грань смотрит вниз: без зеркального U рубашка
+ // читается отражённой, когда карта перевёрнута лицом вниз.
+ uv = front
+ ? new[]
+ {
+ new Vector2(0f, 0f),
+ new Vector2(1f, 0f),
+ new Vector2(1f, 1f),
+ new Vector2(0f, 1f),
+ }
+ : new[]
+ {
+ new Vector2(1f, 0f),
+ new Vector2(0f, 0f),
+ new Vector2(0f, 1f),
+ new Vector2(1f, 1f),
+ },
normals = new[] { normal, normal, normal, normal },
triangles = new[]
{
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs
index f8525ef..c0658e3 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs
@@ -59,6 +59,36 @@ namespace Vanguard.Board3D
view._renderer = renderer;
view.ApplyColor(false);
+ // Тёмная пластина под рамкой: слот читается как площадка, а не
+ // как пустой контур. Цвет — синий для R, тёплый для V.
+ var plate = GameObject.CreatePrimitive(PrimitiveType.Cube);
+ plate.name = "Slot Plate";
+ var plateCollider = plate.GetComponent();
+ 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();
+ 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();
collider.size = new Vector3(
config.cardWidth * 1.12f,
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs
index 11fa2b8..c510911 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs
@@ -13,6 +13,7 @@ namespace Vanguard.Board3D
private MeshRenderer _front;
private MeshRenderer _back;
private MeshRenderer _edge;
+ private MeshRenderer _frame;
private MeshRenderer _highlight;
private BoxCollider _collider;
private MaterialPropertyBlock _block;
@@ -35,6 +36,20 @@ namespace Vanguard.Board3D
private float _arcElapsed;
private float _arcHeight;
+ private bool _revealActive;
+ private float _revealElapsed;
+ private float _revealDuration;
+ private int _revealDirection;
+ private Vector3 _revealFromPosition;
+ private Vector3 _revealFromScale;
+
+ // Пока запрос ждёт в очереди CardMoveAnimator, карта не должна
+ // плавно двигаться к target — иначе она окажется у цели до дуги.
+ private bool _hold;
+ private Vector3 _holdPosition;
+ private Quaternion _holdRotation;
+ private Vector3 _holdScale;
+
private bool _dragging;
private Vector3 _dragOriginPosition;
private Quaternion _dragOriginRotation;
@@ -48,9 +63,20 @@ namespace Vanguard.Board3D
public Vector3 TargetPosition => _targetPosition;
public float TargetYaw => _targetRotation.eulerAngles.y;
public Quaternion TargetRotation => _targetRotation;
+
+ ///
+ /// Переворот рубашкой вверх с сохранением вертикальной ориентации
+ /// рисунка рубашки.
+ ///
+ public static Quaternion FaceDownFlip =>
+ Quaternion.Euler(0f, 180f, 0f) * Quaternion.Euler(180f, 0f, 0f);
+
+ /// Поза рубашкой вверх для текущей базовой ориентации.
+ public Quaternion FaceDownRotation => _baseRotation * FaceDownFlip;
public bool IsAnimating =>
!_dragging
&& (_arcMoving
+ || _revealActive
|| Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
@@ -97,6 +123,21 @@ namespace Vanguard.Board3D
_front.sharedMaterial = BoardMaterials.White;
BoardMaterials.SetShadows(_front, false);
+ var frameGo = new GameObject("Frame");
+ frameGo.transform.SetParent(transform, false);
+ frameGo.transform.localPosition = new Vector3(
+ 0f,
+ (thickness * 0.5f) + 0.0012f,
+ 0f);
+ var frameFilter = frameGo.AddComponent();
+ frameFilter.sharedMesh = BoardMeshLibrary.Frame(
+ width,
+ height,
+ Mathf.Max(0.006f, width * 0.009f));
+ _frame = frameGo.AddComponent();
+ _frame.sharedMaterial = BoardMaterials.Highlight;
+ BoardMaterials.SetShadows(_frame, false);
+
var backGo = new GameObject("Back");
backGo.transform.SetParent(transform, false);
backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f);
@@ -108,12 +149,15 @@ namespace Vanguard.Board3D
var highlightGo = new GameObject("Highlight");
highlightGo.transform.SetParent(transform, false);
- highlightGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0015f, 0f);
+ highlightGo.transform.localPosition = new Vector3(
+ 0f,
+ (thickness * 0.5f) + 0.0018f,
+ 0f);
var filter = highlightGo.AddComponent();
filter.sharedMesh = BoardMeshLibrary.Frame(
- width * 1.16f,
- height * 1.16f,
- Mathf.Max(0.025f, width * 0.035f));
+ width,
+ height,
+ Mathf.Max(0.006f, width * 0.009f));
_highlight = highlightGo.AddComponent();
_highlight.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(_highlight, false);
@@ -122,7 +166,7 @@ namespace Vanguard.Board3D
_stats = BoardText.Create(
transform,
string.Empty,
- 0.028f,
+ 0.034f,
Color.white,
Vector3.zero);
_stats.gameObject.SetActive(false);
@@ -140,7 +184,9 @@ namespace Vanguard.Board3D
name = "Card3D " + reference;
}
- public void SetArt(Texture2D texture, Color accent)
+ public void SetArt(
+ Texture2D texture,
+ Color accent)
{
if (_front == null)
return;
@@ -156,6 +202,19 @@ namespace Vanguard.Board3D
_front.sharedMaterial,
texture != null ? Color.white : accent);
_front.SetPropertyBlock(_block);
+
+ if (_frame != null)
+ {
+ _frame.GetPropertyBlock(_block);
+ _block.Clear();
+ BoardMaterials.SetColor(
+ _block,
+ _frame.sharedMaterial,
+ new Color(0.015f, 0.015f, 0.018f, 1f));
+ _frame.SetPropertyBlock(_block);
+ _frame.gameObject.SetActive(Ref.Zone != BoardZone.Hand);
+ }
+
}
public void SetAnimationSpeeds(float moveSpeed, float rotateSpeed)
@@ -190,7 +249,7 @@ namespace Vanguard.Board3D
_stats.transform.position = new Vector3(
position.x,
position.y + 0.015f,
- position.z - (_config.cardHeight * 0.62f));
+ position.z - (_config.cardHeight * 0.68f));
_stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
}
@@ -207,7 +266,13 @@ namespace Vanguard.Board3D
// Атака/Rest — по часовой стрелке (+90°), урон лежит против (-90°).
var restYaw = rested ? (damageLayout ? -90f : 90f) : 0f;
var rest = Quaternion.Euler(0f, restYaw, 0f);
- var flip = Quaternion.Euler(faceUp ? 0f : 180f, 0f, 0f);
+ // Урон переворачивается вокруг длинной стороны карты, а не
+ // поперёк неё. Обычная рубашка сохраняет вертикальную ориентацию.
+ var flip = faceUp
+ ? Quaternion.identity
+ : damageLayout
+ ? Quaternion.Euler(0f, 0f, 180f)
+ : FaceDownFlip;
_targetRotation = rest * baseRotation * flip;
}
@@ -240,11 +305,60 @@ namespace Vanguard.Board3D
{
_arcMoving = false;
_immediate = false;
+ _hold = false;
+ _revealActive = false;
transform.position = position;
transform.rotation = rotation;
transform.localScale = scale;
}
+ public void HoldAtSource(
+ Vector3 position,
+ Quaternion rotation,
+ Vector3 scale)
+ {
+ _hold = true;
+ _holdPosition = position;
+ _holdRotation = rotation;
+ _holdScale = scale;
+ _immediate = false;
+ _revealActive = false;
+ transform.position = position;
+ transform.rotation = rotation;
+ transform.localScale = scale;
+ }
+
+ public void CancelHold()
+ {
+ _hold = false;
+ }
+
+ public void Reveal(float duration, int direction = 1)
+ {
+ if (duration <= 0.001f)
+ {
+ Snap();
+ return;
+ }
+
+ _dragging = false;
+ _arcMoving = false;
+ _hold = false;
+ _immediate = false;
+ _revealActive = true;
+ _revealElapsed = 0f;
+ _revealDuration = Mathf.Max(0.01f, duration);
+ _revealDirection = direction < 0 ? -1 : 1;
+ _revealFromPosition = _targetPosition + (Vector3.up * 0.12f);
+ _revealFromScale = _targetScale * 0.85f;
+
+ // Начинаем с текущей позы рубашкой вверх и переворачиваем
+ // вокруг длинной оси карты: слева направо, у противника зеркально.
+ transform.position = _revealFromPosition;
+ transform.rotation = _baseRotation * FaceDownFlip;
+ transform.localScale = _revealFromScale;
+ }
+
public void StartArcMove(
Vector3 fromPosition,
Quaternion fromRotation,
@@ -257,6 +371,8 @@ namespace Vanguard.Board3D
_arcMoving = false;
_immediate = false;
+ _hold = false;
+ _revealActive = false;
var distance = Vector3.Distance(fromPosition, _targetPosition);
var angle = Quaternion.Angle(fromRotation, _targetRotation);
@@ -283,10 +399,12 @@ namespace Vanguard.Board3D
public void StopMove(bool snap)
{
- if (!_arcMoving)
+ if (!_arcMoving && !_hold && !_revealActive)
return;
_arcMoving = false;
+ _hold = false;
+ _revealActive = false;
if (snap)
Snap();
}
@@ -318,6 +436,8 @@ namespace Vanguard.Board3D
return;
_dragging = true;
+ _hold = false;
+ _revealActive = false;
_dragOriginPosition = transform.position;
_dragOriginRotation = transform.rotation;
_targetScale = _baseScale * 1.05f;
@@ -326,23 +446,53 @@ namespace Vanguard.Board3D
SetHighlight(null);
}
+ public float DragLift => Ref.Zone == BoardZone.Hand
+ ? _config.cardHeight * _baseScale.x * _config.handHoverLift
+ : _config.dragLift;
+
public void DragTo(Vector3 worldPosition)
{
if (!_dragging)
return;
- var yaw = _targetRotation.eulerAngles.y;
+ // Сохраняем исходную ориентацию карты: карта из руки остаётся
+ // лицом к игроку, а не «ложится» на стол при перетаскивании.
+ // Высота та же, что у hover: 10% высоты карты.
transform.position = new Vector3(
worldPosition.x,
- _config.dragLift + (_config.cardThickness * 0.5f),
+ DragLift + (_config.cardThickness * 0.5f),
worldPosition.z);
- transform.rotation = Quaternion.Euler(0f, yaw, 0f);
+ transform.rotation = _dragOriginRotation;
transform.localScale = _baseScale * 1.05f;
}
+ ///
+ /// Отпустить карту в текущей точке drag-а, не возвращая её в руку.
+ /// Держим её поднятой до ответа сервера или явного возврата.
+ ///
+ public void EndDragAtCurrent()
+ {
+ _dragging = false;
+ if (_collider != null)
+ _collider.enabled = true;
+
+ _hold = true;
+ _holdPosition = transform.position;
+ _holdRotation = transform.rotation;
+ _holdScale = transform.localScale;
+ _targetPosition = _holdPosition;
+ _targetRotation = _holdRotation;
+ _targetScale = _holdScale;
+ _arcMoving = false;
+ _revealActive = false;
+ _immediate = false;
+ }
+
public void DropAt(Vector3 position, Quaternion rotation)
{
_dragging = false;
+ _hold = false;
+ _revealActive = false;
if (_collider != null)
_collider.enabled = true;
_targetPosition = position;
@@ -354,6 +504,8 @@ namespace Vanguard.Board3D
public void ReturnToOrigin()
{
_dragging = false;
+ _hold = false;
+ _revealActive = false;
if (_collider != null)
_collider.enabled = true;
_targetPosition = _dragOriginPosition;
@@ -365,6 +517,8 @@ namespace Vanguard.Board3D
public void Snap()
{
_arcMoving = false;
+ _hold = false;
+ _revealActive = false;
transform.position = _targetPosition;
transform.rotation = _targetRotation;
transform.localScale = _targetScale;
@@ -376,6 +530,36 @@ namespace Vanguard.Board3D
if (_dragging || deltaTime <= 0f)
return;
+ if (_revealActive)
+ {
+ _revealElapsed += deltaTime;
+ var t = Mathf.Clamp01(_revealElapsed / _revealDuration);
+ var eased = t * t * (3f - (2f * t));
+ var angle = 180f * eased * _revealDirection;
+ transform.position = Vector3.Lerp(
+ _revealFromPosition,
+ _targetPosition,
+ eased);
+ transform.rotation = _baseRotation
+ * Quaternion.Euler(0f, 0f, angle)
+ * FaceDownFlip;
+ transform.localScale = Vector3.Lerp(
+ _revealFromScale,
+ _targetScale,
+ eased);
+
+ if (t >= 1f)
+ {
+ _revealActive = false;
+ Snap();
+ }
+
+ return;
+ }
+
+ if (_hold && !_arcMoving)
+ return;
+
if (_arcMoving)
{
_arcElapsed += deltaTime;
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
index b5f8a5a..fa9128f 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
@@ -29,12 +29,40 @@ namespace Vanguard.Board3D
private CameraDirector _cameraDirector;
private readonly List _piles = new List();
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
+ private readonly TextMesh[] _damageCounts = new TextMesh[2];
private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
+ private readonly bool[] _triggerZoneVisible = { true, true };
+ private int _pendingTriggerRevealPlayer = -1;
+ private float _pendingTriggerRevealDuration;
private readonly HashSet _activeHighlights = new HashSet();
private readonly HashSet _selectedHighlights = new HashSet();
private readonly HashSet _validDropSlots = new HashSet();
private readonly List _handBindings = new List();
private bool _guardianDropAllowed;
+ private bool _handVisible = true;
+ private bool _opponentHandVisible = true;
+ private readonly List _opponentHandBindings =
+ new List();
+ private readonly List _drawSelfCards = new List();
+ private readonly List _drawOpponentCards = new List();
+ private readonly List _returningCards = new List();
+ private readonly List _visualHandCards = new List();
+ private readonly HashSet _pendingDrawCards =
+ new HashSet();
+ private readonly List _pendingHandReturns =
+ new List();
+ private readonly Dictionary _handReturnTargets =
+ new Dictionary();
+ private bool _handLayoutOverride;
+ private int _visualHandPlayer = -1;
+ private int _drawIndex;
+ private bool _drawActive;
+ private float _drawDuration;
+ private float _drawArc;
+ private Vector3 _drawSelfSource;
+ private Vector3 _drawOpponentSource;
+ private Vector3 _drawScale;
+ private float _drawNextAt;
private PlaymatLayoutConfig _config;
private PlaymatLayout _layout;
@@ -78,7 +106,11 @@ namespace Vanguard.Board3D
private Pile3D _pressedPile;
private Vector2 _pressPosition;
private bool _dragging;
+ private bool _handTargeting;
private BoardDropTarget? _dragTarget;
+ private Vector3 _dragPointerOffset;
+ private Card3D _hoveredHandCard;
+ private Card3D _dropPendingCard;
public PlaymatLayoutConfig Config => _config;
public PlaymatLayout Layout => _layout;
@@ -87,8 +119,36 @@ namespace Vanguard.Board3D
_arrowDirector != null && _arrowDirector.AttackVisible;
public CardMoveAnimator MoveAnimator => _moveAnimator;
public CameraDirector CameraDirector => _cameraDirector;
+ public bool IsHandDrawActive => _drawActive;
+ public Card3D HoveredHandCard => _hoveredHandCard;
+ public int ReturningCardCount => _returningCards.Count;
+ public IReadOnlyList ReturningCards => _returningCards;
+ public bool StagedReturnHandActive =>
+ _handLayoutOverride && _returningCards.Count > 0;
+ public IReadOnlyList StagedReturnCards => _visualHandCards;
+ public bool StagedDrawHandActive =>
+ _handLayoutOverride && _pendingDrawCards.Count > 0;
+ public IReadOnlyList StagedDrawCards => _visualHandCards;
public BoardArtProvider ArtProvider { get; set; }
+ public bool TryGetHandReturnTarget(
+ Card3D card,
+ out Vector3 position,
+ out Quaternion rotation)
+ {
+ if (card != null
+ && _handReturnTargets.TryGetValue(card, out var target))
+ {
+ position = target.Position;
+ rotation = target.BaseRotation * Card3D.FaceDownFlip;
+ return true;
+ }
+
+ position = default;
+ rotation = default;
+ return false;
+ }
+
public Vector3 TriggerCameraPoint(int player)
{
var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
@@ -96,6 +156,512 @@ namespace Vanguard.Board3D
return position + (Vector3.up * 0.5f);
}
+ public void SetCameraIntro()
+ {
+ EnsureCamera();
+ _cameraDirector?.SetImmediate(
+ _config.introCameraPosition,
+ _config.introCameraTarget,
+ _config.introCameraFov);
+ }
+
+ public void MoveCameraToMain(float duration)
+ {
+ _cameraDirector?.MoveTo(
+ _config.cameraPosition,
+ _config.cameraTarget,
+ _config.cameraFov,
+ duration);
+ }
+
+ public void AnimateHandDraw(float duration)
+ {
+ _handVisible = true;
+ _opponentHandVisible = true;
+ // Сначала фиксируем актуальные позы руки под текущую камеру,
+ // чтобы первая пара не летела к устаревшей цели.
+ UpdateHandView();
+ _drawActive = true;
+ _drawIndex = 0;
+ _drawDuration = duration;
+ _drawArc = _animationConfig != null
+ ? _animationConfig.arcHeight
+ : 0.45f;
+ var selfDraw = GetDrawSource(_selfPlayer);
+ var opponentDraw = GetDrawSource(1 - _selfPlayer);
+ _drawScale = selfDraw.Scale;
+ _drawSelfSource = selfDraw.Position;
+ _drawOpponentSource = opponentDraw.Position;
+
+ _drawSelfCards.Clear();
+ _drawSelfCards.AddRange(
+ _handBindings
+ .Select(binding => binding.Card)
+ .Where(card => card != null));
+ _drawOpponentCards.Clear();
+ _drawOpponentCards.AddRange(
+ _cards.Values
+ .Where(card => card != null
+ && card.Ref.Zone == BoardZone.Hand
+ && card.Ref.Player != _selfPlayer)
+ .OrderBy(card => card.Ref.Index));
+
+ foreach (var card in _drawSelfCards)
+ {
+ card.SetVisible(false);
+ card.HoldAtSource(
+ _drawSelfSource,
+ card.FaceDownRotation,
+ _drawScale);
+ }
+
+ foreach (var card in _drawOpponentCards)
+ {
+ card.SetVisible(false);
+ card.HoldAtSource(
+ _drawOpponentSource,
+ card.FaceDownRotation,
+ _drawScale);
+ }
+
+ _drawNextAt = Time.unscaledTime;
+ }
+
+ private void TickHandDraw()
+ {
+ if (!_drawActive || _moveAnimator == null)
+ return;
+
+ if (_moveAnimator.IsBusy
+ || Time.unscaledTime < _drawNextAt)
+ {
+ return;
+ }
+
+ var count = Mathf.Max(_drawSelfCards.Count, _drawOpponentCards.Count);
+ if (_drawIndex >= count)
+ {
+ _drawActive = false;
+ return;
+ }
+
+ if (_drawIndex < _drawSelfCards.Count)
+ {
+ var card = _drawSelfCards[_drawIndex];
+ if (card != null)
+ {
+ var from = _drawSelfSource
+ + (Vector3.up * (_drawIndex * 0.002f));
+ card.SetVisible(true);
+ _moveAnimator.EnqueueNow(
+ card,
+ from,
+ card.FaceDownRotation,
+ _drawScale,
+ _drawDuration,
+ _drawArc);
+ }
+ }
+
+ if (_drawIndex < _drawOpponentCards.Count)
+ {
+ var card = _drawOpponentCards[_drawIndex];
+ if (card != null)
+ {
+ var from = _drawOpponentSource
+ + (Vector3.up * (_drawIndex * 0.002f));
+ card.SetVisible(true);
+ _moveAnimator.EnqueueNow(
+ card,
+ from,
+ card.FaceDownRotation,
+ _drawScale,
+ _drawDuration,
+ _drawArc);
+ }
+ }
+
+ _drawIndex++;
+ _drawNextAt = Time.unscaledTime + 0.05f;
+ }
+
+ private void ProcessPendingHandReturns(MatchView view)
+ {
+ if (_pendingHandReturns.Count == 0)
+ return;
+
+ if (_previousView == null || view == null)
+ {
+ _pendingHandReturns.Clear();
+ return;
+ }
+
+ var requests = _pendingHandReturns.ToArray();
+ _pendingHandReturns.Clear();
+
+ foreach (var request in requests)
+ {
+ var oldPlayer = _previousView.Players.FirstOrDefault(
+ player => player.Index == request.Player);
+ var newPlayer = view.Players.FirstOrDefault(
+ player => player.Index == request.Player);
+ if (oldPlayer == null || newPlayer == null)
+ continue;
+
+ var candidates = new List();
+ if (request.Player == _selfPlayer)
+ {
+ var newIds = new HashSet(
+ (newPlayer.Hand ?? new List())
+ .Select(card => card.InstanceId));
+ foreach (var pair in _cards)
+ {
+ var card = pair.Value;
+ if (card == null
+ || card.Ref.Zone != BoardZone.Hand
+ || card.Ref.Player != request.Player
+ || newIds.Contains(card.Ref.InstanceId))
+ {
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(request.PrintingId)
+ && !string.Equals(
+ card.Ref.PrintingId,
+ request.PrintingId,
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ candidates.Add(card);
+ }
+ }
+ else
+ {
+ foreach (var pair in _cards)
+ {
+ var card = pair.Value;
+ if (card != null
+ && card.Ref.Zone == BoardZone.Hand
+ && card.Ref.Player == request.Player)
+ {
+ candidates.Add(card);
+ }
+ }
+ }
+
+ if (candidates.Count == 0)
+ continue;
+
+ // Своя рука — справа налево, это убывание индекса.
+ // Для противника тот же порядок индексов зеркален.
+ candidates.Sort((a, b) => b.Ref.Index.CompareTo(a.Ref.Index));
+ var take = Mathf.Min(request.Count, candidates.Count);
+ for (var i = 0; i < take; i++)
+ StartHandReturn(candidates[i], request.Player);
+ }
+ }
+
+ private void BuildVisualHand(MatchView view)
+ {
+ if (_handLayoutOverride
+ || _previousView == null
+ || view == null)
+ {
+ return;
+ }
+
+ var returningPlayer = _returningCards.Count > 0
+ ? _returningCards[0].Ref.Player
+ : -1;
+ var player = returningPlayer >= 0
+ ? returningPlayer
+ : (_pendingDrawCards.Count > 0 ? _selfPlayer : -1);
+ if (player < 0)
+ return;
+
+ _visualHandCards.Clear();
+ _visualHandPlayer = player;
+
+ if (player != _selfPlayer)
+ {
+ foreach (var pair in _cards)
+ {
+ var card = pair.Value;
+ if (card != null
+ && card.Ref.Zone == BoardZone.Hand
+ && card.Ref.Player == player)
+ {
+ _visualHandCards.Add(card);
+ }
+ }
+
+ foreach (var card in _returningCards)
+ {
+ if (card != null
+ && card.Ref.Zone == BoardZone.Hand
+ && card.Ref.Player == player)
+ {
+ _visualHandCards.Add(card);
+ }
+ }
+
+ _visualHandCards.Sort(
+ (a, b) => a.Ref.Index.CompareTo(b.Ref.Index));
+ _handLayoutOverride =
+ _visualHandCards.Count > 0
+ || _returningCards.Count > 0;
+ return;
+ }
+
+ var oldSelf = _previousView.Players.FirstOrDefault(
+ playerView => playerView.Index == player);
+ var newSelf = view.Players.FirstOrDefault(
+ playerView => playerView.Index == player);
+ if (oldSelf?.Hand == null || newSelf?.Hand == null)
+ return;
+
+ var newIds = new HashSet(
+ newSelf.Hand.Select(card => card.InstanceId));
+ var returningIds = new HashSet(
+ _returningCards
+ .Where(card => card != null
+ && card.Ref.Zone == BoardZone.Hand
+ && card.Ref.Player == player)
+ .Select(card => card.Ref.InstanceId));
+
+ var byInstance = new Dictionary();
+ foreach (var pair in _cards)
+ {
+ var card = pair.Value;
+ if (card != null
+ && card.Ref.Zone == BoardZone.Hand
+ && card.Ref.Player == player)
+ {
+ byInstance[card.Ref.InstanceId] = card;
+ }
+ }
+
+ foreach (var card in _returningCards)
+ {
+ if (card != null && card.Ref.Player == player)
+ byInstance[card.Ref.InstanceId] = card;
+ }
+
+ foreach (var oldCard in oldSelf.Hand)
+ {
+ if (oldCard == null)
+ continue;
+
+ var id = oldCard.InstanceId;
+ if (!newIds.Contains(id) && !returningIds.Contains(id))
+ continue;
+
+ if (byInstance.TryGetValue(id, out var card) && card != null)
+ _visualHandCards.Add(card);
+ }
+
+ _handLayoutOverride =
+ _visualHandCards.Count > 0
+ || _returningCards.Count > 0
+ || _pendingDrawCards.Count > 0;
+ }
+
+ private void BeginHandDrawArc(Card3D card)
+ {
+ if (card == null)
+ return;
+
+ card.SetVisible(true);
+ var index = _visualHandCards.Count;
+ var count = index + 1;
+ GetHandViewPose(
+ index,
+ count,
+ out var position,
+ out var rotation,
+ out var scale);
+ card.SetPose(
+ position,
+ rotation,
+ true,
+ false,
+ false,
+ false,
+ scale * Mathf.Max(0.05f, _config.cardScale));
+ }
+
+ private void OnHandDrawCompleted(Card3D card)
+ {
+ if (card == null)
+ return;
+
+ _pendingDrawCards.Remove(card);
+ if (!_visualHandCards.Contains(card))
+ _visualHandCards.Add(card);
+
+ if (_returningCards.Count == 0
+ && _pendingDrawCards.Count == 0)
+ {
+ _handLayoutOverride = false;
+ _visualHandPlayer = -1;
+ }
+
+ UpdateHandView();
+ }
+
+ private void StartHandReturn(Card3D card, int player)
+ {
+ if (card == null || _moveAnimator == null)
+ return;
+
+ var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
+ var deck = player == _selfPlayer ? _deckSelf : _deckOpponent;
+ var deckPosition = deck != null
+ ? deck.transform.position
+ : _layout.DeckPosition(localSeat);
+ var target = deckPosition - (Vector3.up * 0.04f);
+ var baseRotation = Quaternion.Euler(
+ 0f,
+ _layout.BaseYaw(localSeat),
+ 0f);
+ var fromPosition = card.transform.position;
+ var fromRotation = card.transform.rotation;
+ var fromScale = card.transform.localScale;
+
+ card.SetHighlight(null);
+ DetachCard(card);
+ _handReturnTargets[card] = new HandReturnTarget(
+ target,
+ baseRotation,
+ _config.cardScale);
+ _returningCards.Add(card);
+
+ _moveAnimator.Enqueue(
+ card,
+ fromPosition,
+ fromRotation,
+ fromScale,
+ DrawDuration,
+ _animationConfig != null
+ ? _animationConfig.arcHeight
+ : 0.45f,
+ sequential: true,
+ onCompleted: OnHandReturnCompleted,
+ onStart: BeginHandReturnArc,
+ fromCurrent: true);
+ }
+
+ private void BeginHandReturnArc(Card3D card)
+ {
+ if (card == null
+ || !_handReturnTargets.TryGetValue(card, out var target))
+ {
+ return;
+ }
+
+ _handReturnTargets.Remove(card);
+ card.SetPose(
+ target.Position,
+ target.BaseRotation,
+ false,
+ false,
+ false,
+ false,
+ target.Scale);
+ }
+
+ private void DetachCard(Card3D card)
+ {
+ string key = null;
+ foreach (var pair in _cards)
+ {
+ if (pair.Value == card)
+ {
+ key = pair.Key;
+ break;
+ }
+ }
+
+ if (key != null)
+ _cards.Remove(key);
+ }
+
+ private void OnHandReturnCompleted(Card3D card)
+ {
+ if (card == null)
+ return;
+
+ _handReturnTargets.Remove(card);
+ _returningCards.Remove(card);
+ _visualHandCards.Remove(card);
+ if (_returningCards.Count == 0
+ && _pendingDrawCards.Count == 0)
+ {
+ _handLayoutOverride = false;
+ _visualHandPlayer = -1;
+ }
+
+ ReleaseCard(card);
+ UpdateHandView();
+ }
+
+ public void SetTriggerZoneVisible(int player, bool visible)
+ {
+ if (player is < 0 or > 1)
+ return;
+
+ _triggerZoneVisible[player] = visible;
+ foreach (var card in _cards.Values)
+ {
+ if (card == null
+ || card.Ref.Zone != BoardZone.Trigger
+ || card.Ref.Player != player)
+ {
+ continue;
+ }
+
+ card.SetVisible(visible);
+ if (visible)
+ card.Snap();
+ }
+ }
+
+ public void SetTriggerZoneMessage(int player, string text)
+ {
+ if (player is < 0 or > 1)
+ return;
+
+ var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
+ _triggerZones[localSeat]?.SetLabel(text ?? string.Empty);
+ }
+
+ public void RevealTriggerCard(int player, float duration)
+ {
+ if (player is < 0 or > 1)
+ return;
+
+ _pendingTriggerRevealPlayer = player;
+ _pendingTriggerRevealDuration = Mathf.Max(0.01f, duration);
+
+ foreach (var card in _cards.Values)
+ {
+ if (card == null
+ || card.Ref.Zone != BoardZone.Trigger
+ || card.Ref.Player != player)
+ {
+ continue;
+ }
+
+ card.SetVisible(true);
+ card.Reveal(
+ _pendingTriggerRevealDuration,
+ player == _selfPlayer ? 1 : -1);
+ _pendingTriggerRevealPlayer = -1;
+ }
+ }
+
public bool TryGetCardScreenPosition(BoardCardRef reference, out Vector2 screen)
{
screen = default;
@@ -125,6 +691,66 @@ namespace Vanguard.Board3D
_handViewportX = Mathf.Clamp(value, 0.05f, 0.95f);
}
+ public void SetHandVisible(bool visible)
+ {
+ _handVisible = visible;
+ if (!visible)
+ SetHoveredHandCard(null);
+ foreach (var binding in _handBindings)
+ {
+ if (binding.Card != null)
+ binding.Card.SetVisible(visible);
+ }
+ }
+
+ public void SetOpponentHandVisible(bool visible)
+ {
+ _opponentHandVisible = visible;
+ foreach (var card in _cards.Values)
+ {
+ if (card != null
+ && card.Ref.Zone == BoardZone.Hand
+ && card.Ref.Player != _selfPlayer)
+ {
+ card.SetVisible(visible);
+ }
+ }
+ }
+
+ ///
+ /// Вернуть карту, удерживаемую после drag-а, в руку. Вызывается,
+ /// если drop не превратился в интент или сервер его отклонил.
+ ///
+ public void ReturnDroppedCard()
+ {
+ var card = _dropPendingCard;
+ _dropPendingCard = null;
+ if (card == null)
+ return;
+
+ card.CancelHold();
+ UpdateHandView();
+ }
+
+ ///
+ /// Поставить в очередь возврат карт из руки в колоду. Карты уходят
+ /// по дуге под колоду с переворотом рубашкой вверх; следующая
+ /// стартует после завершения предыдущей.
+ ///
+ public void QueueHandReturnToDeck(
+ int player,
+ int count,
+ string printingId = null)
+ {
+ if (player is < 0 or > 1 || count <= 0)
+ return;
+
+ _pendingHandReturns.Add(new HandReturnRequest(
+ player,
+ count,
+ printingId));
+ }
+
public void ConfigureRuntime(
AnimationConfig animationConfig,
InputLockService inputLock)
@@ -248,6 +874,7 @@ namespace Vanguard.Board3D
if (view == null)
{
ClearCards();
+ _pendingHandReturns.Clear();
_view = null;
_previousView = null;
_sourceByInstance = null;
@@ -266,6 +893,7 @@ namespace Vanguard.Board3D
_camera = Camera.main;
_handBindings.Clear();
+ _opponentHandBindings.Clear();
var specs = new List(40);
BuildSpecs(view, state, specs);
@@ -275,6 +903,8 @@ namespace Vanguard.Board3D
? _animationConfig
: AnimationConfig.Default);
+ ProcessPendingHandReturns(view);
+
var previousByInstance = new Dictionary();
var previousKeyByInstance = new Dictionary();
foreach (var pair in _cards)
@@ -296,6 +926,7 @@ namespace Vanguard.Board3D
foreach (var spec in specs)
{
var transferred = false;
+ var oldRef = default(BoardCardRef);
string oldKey = null;
var fromPosition = default(Vector3);
var fromRotation = default(Quaternion);
@@ -311,6 +942,7 @@ namespace Vanguard.Board3D
{
card = existing;
transferred = true;
+ oldRef = existing.Ref;
oldKey = spec.Key;
fromPosition = card.transform.position;
fromRotation = card.transform.rotation;
@@ -330,6 +962,7 @@ namespace Vanguard.Board3D
{
card = previous;
transferred = true;
+ oldRef = previous.Ref;
previousKeyByInstance.TryGetValue(
spec.Ref.InstanceId,
out oldKey);
@@ -347,6 +980,7 @@ namespace Vanguard.Board3D
{
Card = card,
Transferred = transferred,
+ OldRef = oldRef,
OldKey = oldKey,
FromPosition = fromPosition,
FromRotation = fromRotation,
@@ -370,10 +1004,7 @@ namespace Vanguard.Board3D
var card = assignment.Card;
var moved = !immediateAll
&& (!assignment.Transferred
- || !string.Equals(
- assignment.OldKey,
- spec.Key,
- StringComparison.Ordinal));
+ || NeedsMove(assignment.OldRef, spec.Ref));
if (moved && !assignment.Transferred)
{
@@ -385,9 +1016,18 @@ namespace Vanguard.Board3D
assignment.FromPosition,
assignment.FromRotation,
assignment.FromScale);
+
+ if (animateMoves)
+ card.HoldAtSource(
+ assignment.FromPosition,
+ assignment.FromRotation,
+ assignment.FromScale);
}
card.Bind(spec.Ref);
+ if (spec.Ref.Zone == BoardZone.Trigger)
+ card.SetVisible(_triggerZoneVisible[spec.Ref.Player]);
+
card.SetArt(spec.Art.Front, spec.Art.Accent);
card.SetPose(
spec.Position,
@@ -398,29 +1038,105 @@ namespace Vanguard.Board3D
spec.DamageLayout,
spec.Scale * Mathf.Max(0.05f, _config.cardScale));
- if (moved && animateMoves)
+ if (spec.Ref.Zone == BoardZone.Trigger
+ && !_triggerZoneVisible[spec.Ref.Player])
+ {
+ card.SetVisible(false);
+ card.SetStats(spec.Stats, spec.StatsModified);
+ card.SetHighlight(spec.Highlight);
+ continue;
+ }
+
+ if (spec.IsHand)
+ {
+ _handBindings.Add(new HandCardBinding(
+ card,
+ spec.HandIndex,
+ spec.HandCount));
+ }
+ else if (spec.IsOpponentHand)
+ {
+ _opponentHandBindings.Add(new HandCardBinding(
+ card,
+ spec.HandIndex,
+ spec.HandCount));
+ }
+
+ if ((spec.IsHand && !_handVisible)
+ || (spec.IsOpponentHand && !_opponentHandVisible))
+ {
+ card.SetVisible(false);
+ continue;
+ }
+
+ var vanguardReveal = animateMoves
+ && assignment.Transferred
+ && !assignment.OldRef.FaceUp
+ && spec.Ref.FaceUp
+ && spec.Ref.Zone == BoardZone.Vanguard;
+ if (vanguardReveal)
+ {
+ card.Reveal(
+ _animationConfig != null
+ ? _animationConfig.vanguardRevealDuration
+ : 0.8f,
+ spec.Ref.Player == _selfPlayer ? 1 : -1);
+ }
+
+ var revealing = spec.Ref.Zone == BoardZone.Trigger
+ && spec.Ref.Player == _pendingTriggerRevealPlayer;
+ if (revealing)
+ {
+ card.SetVisible(true);
+ card.Reveal(
+ _pendingTriggerRevealDuration,
+ spec.Ref.Player == _selfPlayer ? 1 : -1);
+ _pendingTriggerRevealPlayer = -1;
+ }
+
+ if (moved && animateMoves && !revealing && !vanguardReveal)
{
var important = spec.Ref.Zone is
BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop;
+ var sequentialDraw = !assignment.Transferred
+ && spec.Ref.Zone == BoardZone.Hand;
var duration = important
? _animationConfig != null
? _animationConfig.importantMoveDuration
: 0.9f
- : _animationConfig != null
- ? _animationConfig.moveDuration
- : 0.5f;
+ : sequentialDraw
+ ? DrawDuration
+ : _animationConfig != null
+ ? _animationConfig.moveDuration
+ : 0.5f;
var arc = _animationConfig != null
? _animationConfig.arcHeight
: 0.45f;
+ var stagedDraw = sequentialDraw
+ && spec.Ref.Player == _selfPlayer;
+ if (stagedDraw)
+ {
+ _pendingDrawCards.Add(card);
+ card.SetVisible(false);
+ }
+
_moveAnimator.Enqueue(
card,
assignment.FromPosition,
assignment.FromRotation,
assignment.FromScale,
duration,
- arc);
+ arc,
+ sequentialDraw,
+ onCompleted: stagedDraw
+ ? OnHandDrawCompleted
+ : null,
+ onStart: stagedDraw
+ ? BeginHandDrawArc
+ : null,
+ fromCurrent: stagedDraw);
}
- else if (moved && !animateMoves)
+ else if (moved && !animateMoves && !revealing)
{
Debug.LogWarning(
"[GameBoard3D] Local render moved card "
@@ -430,19 +1146,21 @@ namespace Vanguard.Board3D
card.SetHighlight(spec.Highlight);
card.SetStats(spec.Stats, spec.StatsModified);
+ }
- if (spec.IsHand)
- {
- _handBindings.Add(new HandCardBinding(
- card,
- spec.HandIndex,
- spec.HandCount));
- }
+ if (_dropPendingCard != null
+ && _dropPendingCard.Ref.Zone != BoardZone.Hand)
+ {
+ _dropPendingCard = null;
}
UpdatePiles(view);
UpdateSlots(state);
UpdateAttackArrowState(view);
+ if (animateMoves && !_handLayoutOverride)
+ BuildVisualHand(view);
+ if (_handLayoutOverride)
+ UpdateHandView();
_hasRendered = true;
if (_inputLock != null
@@ -460,7 +1178,11 @@ namespace Vanguard.Board3D
{
_moveAnimator?.CancelAll(snapCards: false);
ClearCards();
+ _pendingHandReturns.Clear();
+ _hoveredHandCard = null;
+ _dropPendingCard = null;
_handBindings.Clear();
+ _handVisible = true;
_view = null;
_previousView = null;
_sourceByInstance = null;
@@ -742,6 +1464,16 @@ namespace Vanguard.Board3D
_config.highlightColor);
_damageZones[localSeat] = zone;
+ _damageCounts[localSeat] = BoardText.Create(
+ _zonesRoot,
+ "0/6",
+ 0.07f,
+ new Color(1f, 0.62f, 0.62f, 0.92f),
+ new Vector3(
+ sideSign * (_config.damageX + 0.02f),
+ 0.02f,
+ zSign * (_config.frontRowZ - 0.25f)));
+
var triggerPosition = _layout.TriggerPosition(localSeat, 0, 1);
_triggerZones[localSeat] = BoardZoneView.Create(
_zonesRoot,
@@ -903,10 +1635,9 @@ namespace Vanguard.Board3D
Art = ResolveArt(card.PrintingId),
Position = _layout.CirclePosition(localSeat, slot),
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
- // Карты в кругах V/R всегда открыты, независимо от снимка.
- FaceUp = true,
+ FaceUp = card.FaceUp,
Rested = card.Rested,
- Stats = "P" + card.Power + " C" + card.Critical,
+ Stats = card.FaceUp ? CardStatsText(card) : null,
StatsModified = card.Power != card.BasePower
|| card.Critical != card.BaseCritical,
Highlight = state.SelectedHighlights.Contains(key)
@@ -917,6 +1648,15 @@ namespace Vanguard.Board3D
});
}
+ private static string CardStatsText(CardView card)
+ {
+ // ▲ — сила, ◆ — крит. Крит показываем только если он выше 1.
+ var text = "\u25B2" + card.Power;
+ if (card.Critical > 1)
+ text += " \u25C6" + card.Critical;
+ return text;
+ }
+
private void AddGuardianSpecs(
MatchView view,
PlayerView player,
@@ -1013,7 +1753,7 @@ namespace Vanguard.Board3D
BaseRotation = Quaternion.Euler(
0f,
_layout.BaseYaw(localSeat)
- + (localSeat == 0 ? 90f : -90f),
+ + (localSeat == 0 ? -90f : 90f),
0f),
FaceUp = true,
Rested = false,
@@ -1060,8 +1800,7 @@ namespace Vanguard.Board3D
Color? highlight = null;
if (state.SelectedHandInstanceIds.Contains(card.InstanceId))
highlight = _config.selectedColor;
- else if (state.AvailableHandInstanceIds.Contains(card.InstanceId)
- || state.MulliganSelecting)
+ else if (state.AvailableHandInstanceIds.Contains(card.InstanceId))
highlight = _config.highlightColor;
specs.Add(new CardSpec
@@ -1115,12 +1854,16 @@ namespace Vanguard.Board3D
var halfHeight = Mathf.Tan(
_camera.fieldOfView * 0.5f * Mathf.Deg2Rad) * _config.handViewDistance;
var halfWidth = halfHeight * Mathf.Max(0.1f, _camera.aspect);
+ // Своя рука заполняется слева направо: первая карта слева,
+ // следующие добавляются правее.
var x = ((_handViewportX - 0.5f) * 2f * halfWidth)
+ (offset * step);
var y = (_config.handViewportY - 0.5f) * 2f * halfHeight
+ (1f - Mathf.Abs(offset) / safeCount) * _config.handViewArc;
- var z = _config.handViewDistance
- + Mathf.Abs(offset) * _config.handViewArc;
+ // Веер строится по X/Y/roll; по глубине каждая следующая карта
+ // чуть ближе к камере, чтобы новая карта всегда была поверх
+ // предыдущей и не проваливалась под неё.
+ var z = _config.handViewDistance - (index * 0.006f);
position = _camera.transform.TransformPoint(new Vector3(x, y, z));
var roll = -offset * _config.handViewRoll;
@@ -1130,9 +1873,132 @@ namespace Vanguard.Board3D
scale = _config.handViewScale;
}
+ private Vector3 ApplyHandHover(
+ Card3D card,
+ Vector3 position,
+ float scale)
+ {
+ if (card == null || card != _hoveredHandCard)
+ return position;
+
+ var cardScale = Mathf.Max(0.05f, _config.cardScale);
+ var up = _camera != null
+ ? _camera.transform.up
+ : Vector3.up;
+ return position + (up * (_config.cardHeight
+ * scale
+ * cardScale
+ * _config.handHoverLift));
+ }
+
private void UpdateHandView()
{
- if (_handBindings.Count == 0)
+ if (_handVisible
+ && _handLayoutOverride
+ && _visualHandPlayer == _selfPlayer)
+ {
+ var count = _visualHandCards.Count;
+ for (var i = 0; i < count; i++)
+ {
+ var card = _visualHandCards[i];
+ if (card == null
+ || card.IsDragging
+ || card.IsMoving)
+ {
+ continue;
+ }
+
+ GetHandViewPose(
+ i,
+ count,
+ out var position,
+ out var rotation,
+ out var scale);
+ position = ApplyHandHover(card, position, scale);
+ card.SetPose(
+ position,
+ rotation,
+ true,
+ false,
+ false,
+ false,
+ scale * Mathf.Max(0.05f, _config.cardScale));
+ }
+ }
+ else if (_handVisible && _handBindings.Count > 0)
+ {
+ if (_camera == null)
+ {
+ _camera = Camera.main;
+ if (_camera == null)
+ return;
+ }
+
+ foreach (var binding in _handBindings)
+ {
+ if (binding.Card == null
+ || binding.Card.IsDragging
+ || binding.Card.IsMoving)
+ continue;
+
+ GetHandViewPose(
+ binding.Index,
+ binding.Count,
+ out var position,
+ out var rotation,
+ out var scale);
+ position = ApplyHandHover(
+ binding.Card,
+ position,
+ scale);
+ binding.Card.SetPose(
+ position,
+ rotation,
+ true,
+ false,
+ false,
+ false,
+ scale * Mathf.Max(0.05f, _config.cardScale));
+ }
+ }
+
+ if (!_opponentHandVisible)
+ return;
+
+ if (_handLayoutOverride
+ && _visualHandPlayer != _selfPlayer)
+ {
+ var count = _visualHandCards.Count;
+ for (var i = 0; i < count; i++)
+ {
+ var card = _visualHandCards[i];
+ if (card == null
+ || card.IsDragging
+ || card.IsMoving)
+ {
+ continue;
+ }
+
+ GetOpponentHandViewPose(
+ i,
+ count,
+ out var position,
+ out var rotation,
+ out var scale);
+ card.SetPose(
+ position,
+ rotation,
+ false,
+ false,
+ false,
+ false,
+ scale * Mathf.Max(0.05f, _config.cardScale));
+ }
+
+ return;
+ }
+
+ if (_opponentHandBindings.Count == 0)
return;
if (_camera == null)
@@ -1142,14 +2008,14 @@ namespace Vanguard.Board3D
return;
}
- foreach (var binding in _handBindings)
+ foreach (var binding in _opponentHandBindings)
{
if (binding.Card == null
|| binding.Card.IsDragging
|| binding.Card.IsMoving)
continue;
- GetHandViewPose(
+ GetOpponentHandViewPose(
binding.Index,
binding.Count,
out var position,
@@ -1158,7 +2024,7 @@ namespace Vanguard.Board3D
binding.Card.SetPose(
position,
rotation,
- true,
+ false,
false,
false,
false,
@@ -1178,21 +2044,78 @@ namespace Vanguard.Board3D
var count = opponent.HandCount;
for (var i = 0; i < count; i++)
{
- var pose = _layout.GetOpponentHandPose(i, count);
+ GetOpponentHandViewPose(
+ i,
+ count,
+ out var position,
+ out var rotation,
+ out var scale);
specs.Add(new CardSpec
{
Key = "OH:" + opponent.Index + ":" + i,
Ref = BoardCardRef.Hand(opponent.Index, i, null),
Art = BoardArt.Fallback(_config.matOpponentColor),
- Position = pose.Position,
- BaseRotation = Quaternion.Euler(-90f, 0f, pose.Yaw),
+ Position = position,
+ BaseRotation = rotation,
FaceUp = false,
Rested = false,
- Scale = _config.opponentHandScale,
+ Scale = scale,
+ IsOpponentHand = true,
+ HandIndex = i,
+ HandCount = count,
});
}
}
+ private void GetOpponentHandViewPose(
+ int index,
+ int count,
+ out Vector3 position,
+ out Quaternion rotation,
+ out float scale)
+ {
+ if (_camera == null)
+ _camera = Camera.main;
+
+ if (_camera == null)
+ {
+ var fallback = _layout.GetOpponentHandPose(index, count);
+ position = fallback.Position;
+ rotation = Quaternion.Euler(-90f, 0f, fallback.Yaw);
+ scale = _config.opponentHandScale;
+ return;
+ }
+
+ var safeCount = Mathf.Max(1, count);
+ var step = _config.opponentHandSpacing;
+ if (safeCount > 1)
+ {
+ step = Mathf.Min(
+ step,
+ _config.opponentHandViewMaxWidth / (safeCount - 1));
+ }
+
+ var offset = index - (safeCount - 1) * 0.5f;
+ var halfHeight = Mathf.Tan(
+ _camera.fieldOfView * 0.5f * Mathf.Deg2Rad)
+ * _config.opponentHandViewDistance;
+ var halfWidth = halfHeight * Mathf.Max(0.1f, _camera.aspect);
+ // Рука противника зеркальна своей: на экране заполняется
+ // справа налево, новая карта появляется слева.
+ var x = -offset * step;
+ var y = (_config.opponentHandViewportY - 0.5f) * 2f * halfHeight
+ + (1f - Mathf.Abs(offset) / safeCount)
+ * _config.opponentHandViewArc;
+ var z = _config.opponentHandViewDistance - (index * 0.006f);
+
+ position = _camera.transform.TransformPoint(new Vector3(x, y, z));
+ var roll = offset * _config.opponentHandViewRoll;
+ rotation = _camera.transform.rotation
+ * Quaternion.Euler(0f, 0f, roll)
+ * Quaternion.Euler(-90f, 0f, 0f);
+ scale = _config.opponentHandViewScale;
+ }
+
private void UpdatePiles(MatchView view)
{
var self = view.Players.FirstOrDefault(player => player.Index == view.You);
@@ -1204,10 +2127,24 @@ namespace Vanguard.Board3D
_damageZones[0]?.SetLabel(string.Empty);
_damageZones[1]?.SetLabel(string.Empty);
+ UpdateDamageCount(self, 0);
+ UpdateDamageCount(opponent, 1);
+
if (_guardianZone != null)
_guardianZone.SetLabel(string.Empty);
}
+ private void UpdateDamageCount(PlayerView player, int localSeat)
+ {
+ if (localSeat is < 0 or > 1)
+ return;
+
+ var count = player != null
+ ? player.FaceDownDamage + (player.FaceUpDamage?.Count ?? 0)
+ : 0;
+ BoardText.SetText(_damageCounts[localSeat], count + "/6");
+ }
+
private void UpdatePileSide(
PlayerView player,
Pile3D deck,
@@ -1294,6 +2231,7 @@ namespace Vanguard.Board3D
var delta = Time.unscaledDeltaTime;
_moveAnimator?.Tick(delta);
+ TickHandDraw();
_cameraDirector?.Tick(delta);
foreach (var card in _cards.Values)
{
@@ -1301,6 +2239,18 @@ namespace Vanguard.Board3D
card.Tick(delta);
}
+ for (var i = _returningCards.Count - 1; i >= 0; i--)
+ {
+ var card = _returningCards[i];
+ if (card == null || !card.gameObject.activeInHierarchy)
+ {
+ _returningCards.RemoveAt(i);
+ continue;
+ }
+
+ card.Tick(delta);
+ }
+
_arrowDirector?.Tick(delta);
UpdateAttackArrowPoints();
@@ -1308,6 +2258,7 @@ namespace Vanguard.Board3D
{
if (_mode != PointerMode.None || _dragging)
CancelPointer();
+ SetHoveredHandCard(null);
return;
}
@@ -1345,16 +2296,82 @@ namespace Vanguard.Board3D
return;
if (!TryReadPointer(out var pointer, out var pressed, out var held, out var released))
+ {
+ SetHoveredHandCard(null);
return;
+ }
+
+ UpdateHandHover(pointer);
+
+ if (_handTargeting)
+ {
+ // Карта из руки остаётся в руке; двигается только цель.
+ UpdateHandTargeting(pointer);
+ if (released)
+ FinishHandTargeting(pointer);
+ return;
+ }
+
+ if (_dragging)
+ {
+ // Карта должна следовать за курсором каждый кадр, а не
+ // только в момент старта drag-а и на отпускании.
+ UpdateDrag(pointer);
+ if (released)
+ FinishDrag(pointer);
+ return;
+ }
if (pressed)
OnPointerPressed(pointer);
- else if (held && _mode == PointerMode.Card && !_dragging)
+ else if (held && _mode == PointerMode.Card)
OnPointerHeld(pointer);
else if (released)
OnPointerReleased(pointer);
}
+ private void UpdateHandHover(Vector2 pointer)
+ {
+ if (_handTargeting)
+ {
+ SetHoveredHandCard(_pressedCard);
+ return;
+ }
+
+ if (!_handVisible
+ || _handLayoutOverride
+ || _drawActive
+ || _returningCards.Count > 0
+ || _dragging
+ || IsPointerOverUi())
+ {
+ SetHoveredHandCard(null);
+ return;
+ }
+
+ var ray = GetRay(pointer);
+ if (!TryRaycast(ray, out var card, out _, out _)
+ || card == null
+ || card.Ref.Zone != BoardZone.Hand
+ || card.Ref.Player != _selfPlayer
+ || card.IsMoving)
+ {
+ SetHoveredHandCard(null);
+ return;
+ }
+
+ SetHoveredHandCard(card);
+ }
+
+ private void SetHoveredHandCard(Card3D card)
+ {
+ if (_hoveredHandCard == card)
+ return;
+
+ _hoveredHandCard = card;
+ UpdateHandView();
+ }
+
private static bool TryReadPointer(
out Vector2 pointer,
out bool pressed,
@@ -1390,6 +2407,8 @@ namespace Vanguard.Board3D
private void OnPointerPressed(Vector2 pointer)
{
+ if (_dropPendingCard != null)
+ ReturnDroppedCard();
CancelPointer();
if (IsPointerOverUi())
return;
@@ -1436,17 +2455,55 @@ namespace Vanguard.Board3D
< Mathf.Max(2f, _config.dragThresholdPixels))
return;
+ if (_pressedCardRef.IsHand)
+ {
+ _handTargeting = true;
+ SetHoveredHandCard(_pressedCard);
+ ApplySlotHighlights();
+ UpdateHandTargeting(pointer);
+ return;
+ }
+
_dragging = true;
_pressedCard.BeginDrag();
+ _dragPointerOffset = Vector3.zero;
+
+ var dragRay = GetRay(pointer);
+ var dragPlane = GetDragPlane(_pressedCard);
+ if (dragPlane.Raycast(dragRay, out var enter))
+ {
+ var point = dragRay.GetPoint(enter);
+ var origin = _pressedCard.transform.position;
+ _dragPointerOffset = new Vector3(
+ origin.x - point.x,
+ 0f,
+ origin.z - point.z);
+ }
+
ApplySlotHighlights();
- UpdateDrag(pointer);
+ // Карта начнёт движение только со следующего кадра: в момент
+ // начала drag-а она остаётся в исходной позе руки.
+ }
+
+ private Plane GetDragPlane(Card3D card)
+ {
+ var height = (card != null ? card.DragLift : _config.dragLift)
+ + (_config.cardThickness * 0.5f);
+ return new Plane(
+ Vector3.up,
+ new Vector3(0f, height, 0f));
}
private void OnPointerReleased(Vector2 pointer)
{
+ if (_handTargeting)
+ {
+ FinishHandTargeting(pointer);
+ return;
+ }
+
if (_dragging)
{
- UpdateDrag(pointer);
FinishDrag(pointer);
return;
}
@@ -1467,17 +2524,129 @@ namespace Vanguard.Board3D
CancelPointer();
}
+ private void UpdateHandTargeting(Vector2 pointer)
+ {
+ if (_pressedCard == null)
+ return;
+
+ var ray = GetRay(pointer);
+ if (TryResolveHandTargetFromRay(ray, out var rayTarget))
+ {
+ SetHandTarget(rayTarget);
+ return;
+ }
+
+ var plane = new Plane(
+ Vector3.up,
+ new Vector3(0f, _config.cardLift, 0f));
+ if (!plane.Raycast(ray, out var enter))
+ {
+ ClearHandTarget();
+ return;
+ }
+
+ var point = ray.GetPoint(enter);
+ point.x = Mathf.Clamp(
+ point.x,
+ -_config.boardHalfWidth,
+ _config.boardHalfWidth);
+ point.z = Mathf.Clamp(
+ point.z,
+ -_config.boardHalfLength,
+ _config.boardHalfLength);
+
+ if (_layout.TryFindDropTarget(point, _selfPlayer, out var target))
+ SetHandTarget(target);
+ else
+ ClearHandTarget();
+ }
+
+ private bool TryResolveHandTargetFromRay(
+ Ray ray,
+ out BoardDropTarget target)
+ {
+ target = default;
+ if (!TryRaycast(ray, out var card, out var slot, out _))
+ return false;
+
+ if (card != null
+ && card.Ref.Zone is BoardZone.Vanguard
+ or BoardZone.RearGuard
+ or BoardZone.Guardian)
+ {
+ target = new BoardDropTarget(
+ card.Ref.Zone,
+ card.Ref.Player,
+ card.Ref.Slot,
+ card.Ref.Index);
+ return true;
+ }
+
+ if (slot != null)
+ {
+ var slotRef = slot.Ref;
+ target = new BoardDropTarget(
+ slotRef.Zone,
+ slotRef.Player,
+ slotRef.Slot,
+ slotRef.Index);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void SetHandTarget(BoardDropTarget target)
+ {
+ _dragTarget = target;
+ ShowDropIndicator(target);
+ var key = new BoardSlotKey(target.Player, target.Slot);
+ var valid = target.Zone == BoardZone.Guardian
+ ? _guardianDropAllowed
+ : _validDropSlots.Contains(key);
+ _dropIndicator?.SetColor(
+ valid ? _config.selectedColor : _config.damageColor);
+ }
+
+ private void ClearHandTarget()
+ {
+ _dragTarget = null;
+ if (_dropIndicator != null)
+ _dropIndicator.gameObject.SetActive(false);
+ }
+
+ private void FinishHandTargeting(Vector2 pointer)
+ {
+ var source = _pressedCard != null
+ ? _pressedCard.Ref
+ : _pressedCardRef;
+ var target = _dragTarget;
+ _handTargeting = false;
+ _dragTarget = null;
+ _dropIndicator?.gameObject.SetActive(false);
+
+ if (target.HasValue && !IsPointerOverUi())
+ {
+ Debug.Log($"[HandTargeting] drop source={source.Zone}:{source.Player}:{source.PrintingId} target={target.Value.Zone}:{target.Value.Player}:{target.Value.Slot}");
+ CardDropped?.Invoke(source, target.Value.ToRef());
+ }
+
+ CancelPointer();
+ }
+
private void UpdateDrag(Vector2 pointer)
{
if (!_dragging || _pressedCard == null)
return;
var ray = GetRay(pointer);
- var plane = new Plane(Vector3.up, new Vector3(0f, _config.cardLift, 0f));
+ var plane = GetDragPlane(_pressedCard);
if (!plane.Raycast(ray, out var enter))
return;
var point = ray.GetPoint(enter);
+ point.x += _dragPointerOffset.x;
+ point.z += _dragPointerOffset.z;
point.x = Mathf.Clamp(point.x, -_config.boardHalfWidth, _config.boardHalfWidth);
point.z = Mathf.Clamp(point.z, -_config.boardHalfLength, _config.boardHalfLength);
_pressedCard.DragTo(point);
@@ -1510,12 +2679,15 @@ namespace Vanguard.Board3D
if (card == null)
{
+ _dropPendingCard = null;
CancelPointer();
return;
}
if (IsPointerOverUi() || !target.HasValue)
{
+ if (_dropPendingCard == card)
+ _dropPendingCard = null;
card.ReturnToOrigin();
CancelPointer();
return;
@@ -1525,27 +2697,19 @@ namespace Vanguard.Board3D
var targetRef = targetValue.ToRef();
if (source.IsHand)
- _handBindings.RemoveAll(binding => binding.Card == card);
-
- // Атака — это клик по цели, карта остаётся на своём круге.
- if ((source.IsField && targetValue.Player != source.Player)
- || (source.IsHand && targetValue.Player != source.Player
- && targetValue.Zone != BoardZone.Guardian))
{
- card.ReturnToOrigin();
+ // Не возвращаем карту в руку до ответа сервера: она должна
+ // остаться приподнятой в точке drop-а и затем плавно уйти
+ // в VC/RC. Если drop отклонён, BattleView вызовет
+ // ReturnDroppedCard.
+ card.EndDragAtCurrent();
+ _dropPendingCard = card;
}
else
{
- var localSeat = _layout.ToLocalSeat(targetValue.Player, _selfPlayer);
- var position = targetValue.Zone == BoardZone.Guardian
- ? _layout.GuardianPosition(localSeat, 0, 1)
- : _layout.CirclePosition(localSeat, targetValue.Slot);
- var restYaw = !source.IsHand && card.IsRested ? 90f : 0f;
- var rotation = Quaternion.Euler(
- 0f,
- _layout.BaseYaw(localSeat) + restYaw,
- 0f);
- card.DropAt(position, rotation);
+ // Для карт поля сохраняем прежнее поведение: серверный
+ // снапшот сам построит дугу из исходной позиции.
+ card.ReturnToOrigin();
}
CardDropped?.Invoke(source, targetRef);
@@ -1660,13 +2824,33 @@ namespace Vanguard.Board3D
return;
_moveAnimator?.CancelCard(card);
+ if (_hoveredHandCard == card)
+ _hoveredHandCard = null;
card.SetHighlight(null);
card.gameObject.SetActive(false);
+ _pendingDrawCards.Remove(card);
+ _visualHandCards.Remove(card);
+ if (_returningCards.Count == 0
+ && _pendingDrawCards.Count == 0)
+ {
+ _handLayoutOverride = false;
+ _visualHandPlayer = -1;
+ }
+
_pool.Push(card);
}
private void ClearCards()
{
+ foreach (var card in _returningCards)
+ ReleaseCard(card);
+ _returningCards.Clear();
+ _visualHandCards.Clear();
+ _pendingDrawCards.Clear();
+ _handLayoutOverride = false;
+ _visualHandPlayer = -1;
+ _handReturnTargets.Clear();
+
foreach (var card in _cards.Values)
ReleaseCard(card);
_cards.Clear();
@@ -1681,12 +2865,29 @@ namespace Vanguard.Board3D
_pressedSlot = null;
_pressedPile = null;
_dragging = false;
+ _handTargeting = false;
_dragTarget = null;
+ _dragPointerOffset = Vector3.zero;
if (_dropIndicator != null)
_dropIndicator.gameObject.SetActive(false);
ApplySlotHighlights();
}
+ private float DrawDuration => _animationConfig != null
+ ? _animationConfig.introDrawDuration
+ : 0.6f;
+
+ private (Vector3 Position, Vector3 Scale) GetDrawSource(int player)
+ {
+ var localSeat = _layout.ToLocalSeat(player, _selfPlayer);
+ var deck = player == _selfPlayer ? _deckSelf : _deckOpponent;
+ var position = (deck != null
+ ? deck.transform.position
+ : _layout.DeckPosition(localSeat))
+ + (Vector3.up * 0.03f);
+ return (position, Vector3.one * _config.cardScale);
+ }
+
private (Vector3 Position, Quaternion Rotation, Vector3 Scale) ResolveSourcePose(
CardSpec spec)
{
@@ -1710,9 +2911,22 @@ namespace Vanguard.Board3D
return (pose.Position, pose.Rotation, scale);
}
- // Fallback: карта пришла из колоды — начинаем рубашкой вверх.
- var position = _layout.DeckPosition(localSeat);
- var rotation = baseYaw * Quaternion.Euler(180f, 0f, 0f);
+ // Новая карта руки: общий старт добора — над колодой, полного
+ // размера, рубашкой вверх в ориентации будущей карты руки.
+ if (spec.Ref.Zone == BoardZone.Hand)
+ {
+ var draw = GetDrawSource(spec.Ref.Player);
+ return (
+ draw.Position,
+ spec.BaseRotation * Card3D.FaceDownFlip,
+ draw.Scale);
+ }
+
+ // Fallback: карта пришла из колоды — появляется над колодой
+ // рубашкой вверх и летит в руку с переворотом.
+ var position = _layout.DeckPosition(localSeat)
+ + (Vector3.up * 0.03f);
+ var rotation = baseYaw * Card3D.FaceDownFlip;
return (position, rotation, scale);
}
@@ -1795,6 +3009,29 @@ namespace Vanguard.Board3D
return existing.Ref.InstanceId == spec.Ref.InstanceId;
}
+ private static bool NeedsMove(BoardCardRef oldRef, BoardCardRef newRef)
+ {
+ if (oldRef.Zone == newRef.Zone && oldRef.Player == newRef.Player)
+ {
+ // Внутри одной зоны карты могут менять индекс/ключ из-за
+ // добавления или удаления соседней карты. Это перекомпоновка,
+ // а не перемещение по столу: дуговая анимация здесь лишняя.
+ switch (newRef.Zone)
+ {
+ case BoardZone.Hand:
+ case BoardZone.Guardian:
+ case BoardZone.Damage:
+ case BoardZone.Trigger:
+ return false;
+ }
+ }
+
+ return oldRef.Zone != newRef.Zone
+ || oldRef.Player != newRef.Player
+ || oldRef.Slot != newRef.Slot
+ || oldRef.Index != newRef.Index;
+ }
+
private BoardArt ResolveArt(string printingId)
{
if (ArtProvider != null)
@@ -1898,12 +3135,44 @@ namespace Vanguard.Board3D
{
public Card3D Card;
public bool Transferred;
+ public BoardCardRef OldRef;
public string OldKey;
public Vector3 FromPosition;
public Quaternion FromRotation;
public Vector3 FromScale;
}
+ private readonly struct HandReturnRequest
+ {
+ public readonly int Player;
+ public readonly int Count;
+ public readonly string PrintingId;
+
+ public HandReturnRequest(int player, int count, string printingId)
+ {
+ Player = player;
+ Count = count;
+ PrintingId = printingId;
+ }
+ }
+
+ private readonly struct HandReturnTarget
+ {
+ public readonly Vector3 Position;
+ public readonly Quaternion BaseRotation;
+ public readonly float Scale;
+
+ public HandReturnTarget(
+ Vector3 position,
+ Quaternion baseRotation,
+ float scale)
+ {
+ Position = position;
+ BaseRotation = baseRotation;
+ Scale = scale;
+ }
+ }
+
private readonly struct SourcePose
{
public readonly Vector3 Position;
@@ -1930,6 +3199,7 @@ namespace Vanguard.Board3D
public bool DamageLayout;
public float Scale = 1f;
public bool IsHand;
+ public bool IsOpponentHand;
public int HandIndex;
public int HandCount;
public string Stats;
diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs
index 871b9e5..92b02e1 100644
--- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs
+++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -33,7 +33,6 @@ namespace Vanguard.UI
private readonly AnimationConfig _animationConfig;
private readonly InputLockService _inputLock;
private TriggerCheckSequence _triggerSequence;
- private bool _feedbackLockHeld;
private string _lastInputLockSummary;
private readonly Dictionary _catalog =
new Dictionary(StringComparer.Ordinal);
@@ -41,23 +40,17 @@ namespace Vanguard.UI
private readonly RectTransform _root;
private RectTransform _intents;
- private readonly Text _topInfo;
- private readonly Text _clock;
- private readonly Text _status;
- private readonly Button _concede;
- private readonly Text _concedeLabel;
- private readonly Button _reconnect;
+ private Text _topInfo;
+ private Text _clock;
+ private Text _bank;
+ private Text _status;
+ private float _statusHideAt;
+ private Button _concede;
+ private Text _concedeLabel;
+ private Button _reconnect;
private readonly RectTransform _result;
private RectTransform _logContent;
private Text _resultText;
- private Text _feedback;
- private Image _flash;
- private float _feedbackTime;
- private float _feedbackDuration;
- private Color _feedbackColor;
- private readonly Queue _feedbackQueue = new Queue();
- private Button _skipFeedback;
- private bool _feedbackPlaying;
private readonly List _logLines = new List();
private GameConnection _connection;
@@ -112,12 +105,13 @@ namespace Vanguard.UI
private readonly HashSet _highlightSlots = new HashSet();
private GameBoard3D _board3D;
private OverlayNavigationService _overlays;
- private ZoneBlockStripView _selfZones;
- private ZoneBlockStripView _opponentZones;
+ private PlayerStatsPanelView _selfZones;
+ private PlayerStatsPanelView _opponentZones;
private CardActionMenuView _cardMenu;
private RectTransform _intentPanel;
private Text _intentPanelToggleLabel;
- private bool _intentPanelVisible = true;
+ private bool _intentPanelVisible;
+ private RectTransform _confirmationBar;
private RectTransform _phasePanel;
private RectTransform _combatStats;
private Text _combatStatsText;
@@ -146,109 +140,129 @@ namespace Vanguard.UI
rootImage.raycastTarget = false;
}
- var top = UiKit.CreatePanel(_root, "TopBar", Panel);
+ var top = UiKit.CreatePanel(_root, "TopBar", new Color(0.02f, 0.035f, 0.055f, 0.92f));
top.anchorMin = new Vector2(0f, 1f);
top.anchorMax = new Vector2(1f, 1f);
top.pivot = new Vector2(0.5f, 1f);
top.anchoredPosition = Vector2.zero;
- top.sizeDelta = new Vector2(0f, 88f);
- UiKit.AddHorizontalLayout(top, 20, 14);
+ top.sizeDelta = new Vector2(0f, 76f);
+ UiKit.AddHorizontalLayout(top, 16, 12);
- var title = UiKit.CreateText(top, "Title", "Бой", 32, Color.white, TextAnchor.MiddleLeft);
- title.fontStyle = FontStyle.Bold;
- UiKit.SetPreferredHeight(title, 46f, 0f);
+ var matchInfo = UiKit.CreatePanel(top, "MatchInfo", Color.clear);
+ UiKit.AddVerticalLayout(matchInfo, 0, 0);
+ var infoElement = matchInfo.gameObject.AddComponent();
+ infoElement.preferredWidth = 460f;
+ infoElement.flexibleWidth = 1f;
+ infoElement.minWidth = 260f;
_topInfo = UiKit.CreateText(
- top, "Match", string.Empty, 17, Muted, TextAnchor.MiddleLeft);
+ matchInfo, "TurnPhase", "Turn 1", 19, Color.white, TextAnchor.MiddleLeft);
+ _topInfo.fontStyle = FontStyle.Bold;
+ UiKit.SetPreferredHeight(_topInfo, 26f);
UiKit.MakeLiteral(_topInfo);
- UiKit.SetPreferredHeight(_topInfo, 38f);
+
+ var clockPanel = UiKit.CreatePanel(top, "ClockPanel", Color.clear);
+ UiKit.AddVerticalLayout(clockPanel, 0, 0);
+ var clockElement = clockPanel.gameObject.AddComponent();
+ clockElement.preferredWidth = 140f;
+ clockElement.minWidth = 120f;
_clock = UiKit.CreateText(
- top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight);
- UiKit.SetPreferredHeight(_clock, 44f, 0f);
+ clockPanel, "Clock", "—", 21, Color.white, TextAnchor.MiddleCenter);
+ UiKit.SetPreferredHeight(_clock, 28f);
+
+ _bank = UiKit.CreateText(
+ clockPanel, "Bank", "Банк —", 12, Muted, TextAnchor.MiddleCenter);
+ UiKit.SetPreferredHeight(_bank, 16f);
var toggle = UiKit.CreateButton(
top,
"ToggleIntentPanel",
- "Скрыть панель",
+ "Показать панель",
ToggleIntentPanel,
- 16);
- var toggleElement = UiKit.SetPreferredHeight(toggle, 44f, 0f);
- toggleElement.preferredWidth = 168f;
+ 13);
+ var toggleElement = UiKit.SetPreferredHeight(toggle, 40f, 0f);
+ toggleElement.preferredWidth = 164f;
_intentPanelToggleLabel = toggle.GetComponentInChildren();
UiKit.MakeLiteral(_intentPanelToggleLabel);
- _concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 18);
+ _concede = UiKit.CreateButton(top, "Concede", "Сдаться", OnConcedeClicked, 15);
_concedeLabel = _concede.GetComponentInChildren();
- UiKit.SetPreferredHeight(_concede, 44f, 0f);
+ UiKit.SetPreferredHeight(_concede, 40f, 0f);
+ var concedeElement = _concede.GetComponent();
+ if (concedeElement != null)
+ concedeElement.preferredWidth = 108f;
+
+ _reconnect = UiKit.CreateButton(
+ top, "Reconnect", "Переподключиться", OnReconnectClicked, 13);
+ UiKit.SetPreferredHeight(_reconnect, 40f, 0f);
+ _reconnect.gameObject.SetActive(false);
BuildActionPrompt();
- var right = UiKit.CreatePanel(_root, "IntentPanel", Panel);
- _intentPanel = right;
- right.anchorMin = new Vector2(1f, 0f);
- right.anchorMax = new Vector2(1f, 1f);
- right.pivot = new Vector2(1f, 0.5f);
- right.offsetMin = new Vector2(-456f, 28f);
- right.offsetMax = new Vector2(-36f, -108f);
- UiKit.AddVerticalLayout(right, 14, 10);
-
- var actionsTitle = UiKit.CreateText(
- right, "ActionsTitle", "Действия", 24, Color.white, TextAnchor.MiddleLeft);
- actionsTitle.fontStyle = FontStyle.Bold;
- UiKit.SetPreferredHeight(actionsTitle, 32f);
-
- var scroll = UiKit.CreateScrollView(right, "IntentScroll", out _intents, 6f);
- var scrollElement = scroll.gameObject.AddComponent();
- scrollElement.flexibleHeight = 1f;
- scrollElement.minHeight = 200f;
+ // Компактная панель подтверждений: без заголовка и фона, растёт
+ // снизу вверх по количеству доступных кнопок.
+ _confirmationBar = UiKit.CreatePanel(
+ _root,
+ "ConfirmationBar",
+ Color.clear);
+ _confirmationBar.anchorMin = new Vector2(1f, 0f);
+ _confirmationBar.anchorMax = new Vector2(1f, 0f);
+ _confirmationBar.pivot = new Vector2(1f, 0f);
+ _confirmationBar.anchoredPosition = new Vector2(-28f, 28f);
+ _confirmationBar.sizeDelta = new Vector2(300f, 0f);
+ var barImage = _confirmationBar.GetComponent();
+ if (barImage != null)
+ barImage.raycastTarget = false;
+ UiKit.AddVerticalLayout(_confirmationBar, 0, 6);
+ var barFitter = _confirmationBar.gameObject.AddComponent();
+ barFitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
+ barFitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
+ _intents = _confirmationBar;
_status = UiKit.CreateText(
- right, "Status", string.Empty, 16, Muted, TextAnchor.UpperLeft);
+ _root, "StatusToast", string.Empty, 15, Color.white, TextAnchor.MiddleLeft);
+ var statusRect = _status.rectTransform;
+ statusRect.anchorMin = new Vector2(0f, 1f);
+ statusRect.anchorMax = new Vector2(0f, 1f);
+ statusRect.pivot = new Vector2(0f, 1f);
+ statusRect.anchoredPosition = new Vector2(24f, -86f);
+ statusRect.sizeDelta = new Vector2(720f, 44f);
_status.horizontalOverflow = HorizontalWrapMode.Wrap;
- UiKit.SetPreferredHeight(_status, 74f);
+ _status.raycastTarget = false;
+ _status.gameObject.SetActive(false);
- _skipFeedback = UiKit.CreateButton(
- right, "SkipFeedback", "Пропустить анимации", SkipFeedback, 15);
- UiKit.SetPreferredHeight(_skipFeedback, 38f);
- _skipFeedback.gameObject.SetActive(false);
+ _intentPanel = UiKit.CreatePanel(
+ _root, "DebugPanel", new Color(0.02f, 0.035f, 0.055f, 0.94f));
+ _intentPanel.anchorMin = new Vector2(1f, 0f);
+ _intentPanel.anchorMax = new Vector2(1f, 1f);
+ _intentPanel.pivot = new Vector2(1f, 0.5f);
+ _intentPanel.offsetMin = new Vector2(-456f, 28f);
+ _intentPanel.offsetMax = new Vector2(-28f, -96f);
+ UiKit.AddVerticalLayout(_intentPanel, 14, 8);
+
+ var actionsTitle = UiKit.CreateText(
+ _intentPanel, "ActionsTitle", "DEBUG", 20, Color.white, TextAnchor.MiddleLeft);
+ actionsTitle.fontStyle = FontStyle.Bold;
+ UiKit.SetPreferredHeight(actionsTitle, 28f);
var logTitle = UiKit.CreateText(
- right, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
+ _intentPanel, "LogTitle", "Журнал", 16, new Color(0.72f, 0.80f, 0.94f), TextAnchor.MiddleLeft);
logTitle.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(logTitle, 22f);
- var logScroll = UiKit.CreateScrollView(right, "LogScroll", out _logContent, 3f);
+ var logScroll = UiKit.CreateScrollView(_intentPanel, "LogScroll", out _logContent, 3f);
var logElement = logScroll.gameObject.AddComponent();
- logElement.preferredHeight = 142f;
- logElement.minHeight = 80f;
+ logElement.flexibleHeight = 1f;
+ logElement.minHeight = 160f;
- _reconnect = UiKit.CreateButton(right, "Reconnect", "Переподключиться", OnReconnectClicked, 18);
- UiKit.SetPreferredHeight(_reconnect, 46f);
- _reconnect.gameObject.SetActive(false);
+ _intentPanelVisible = false;
+ _intentPanel.gameObject.SetActive(false);
_result = BuildResultPanel(_root);
_result.gameObject.SetActive(false);
- _flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent();
- _flash.raycastTarget = false;
- _flash.gameObject.SetActive(false);
-
- _feedback = UiKit.CreateText(
- _root, "BattleFeedback", string.Empty, 44,
- Color.white, TextAnchor.MiddleCenter);
- _feedback.fontStyle = FontStyle.Bold;
- _feedback.raycastTarget = false;
- var feedbackRect = _feedback.rectTransform;
- feedbackRect.anchorMin = new Vector2(0.5f, 0.5f);
- feedbackRect.anchorMax = new Vector2(0.5f, 0.5f);
- feedbackRect.pivot = new Vector2(0.5f, 0.5f);
- feedbackRect.sizeDelta = new Vector2(900f, 120f);
- feedbackRect.anchoredPosition = new Vector2(0f, 130f);
- _feedback.gameObject.SetActive(false);
-
BuildOverlaysAndZones();
- BuildPhasePanel();
BuildCombatStats();
BuildAbilityOverlay();
BuildCardChoiceOverlay();
@@ -261,6 +275,9 @@ namespace Vanguard.UI
_triggerSequence = new TriggerCheckSequence(
_board3D.CameraDirector,
_inputLock);
+ _triggerSequence.FocusChanged += OnTriggerFocusChanged;
+ _triggerSequence.ZoomCompleted += OnTriggerZoomCompleted;
+ _triggerSequence.ResolveReady += OnTriggerResolveReady;
_board3D.ArtProvider = ResolveBoardArt;
_board3D.CardClicked += OnBoard3DCardClicked;
_board3D.CardRightClicked += OnBoard3DCardRightClicked;
@@ -281,6 +298,14 @@ namespace Vanguard.UI
_finished = false;
_triggerWasPending = false;
_lastTriggerKey = null;
+ _triggerZonePlayer = -1;
+ _triggerZoneNoTrigger = false;
+ _triggerZoomStartedByEvent = false;
+ _suppressNextDamageZoom = false;
+ _vanguardRevealActive = false;
+ _introActive = false;
+ _introStage = 0;
+ _introDeadline = 0f;
_triggerSequence?.Cancel();
_confirmConcede = false;
_concedeLabel.text = "Сдаться";
@@ -309,34 +334,30 @@ namespace Vanguard.UI
_autoFinishMulliganAt = 0f;
_autoEndBattleArmed = false;
_autoEndBattleAt = 0f;
- _intentPanelVisible = true;
+ _intentPanelVisible = false;
if (_intentPanel != null)
- _intentPanel.gameObject.SetActive(true);
+ _intentPanel.gameObject.SetActive(false);
if (_intentPanelToggleLabel != null)
{
- _intentPanelToggleLabel.text = "Скрыть панель";
+ _intentPanelToggleLabel.text = "Показать панель";
UiKit.MakeLiteral(_intentPanelToggleLabel);
}
ApplyIntentPanelLayout();
_inputLock.ReleaseAll();
- _feedbackLockHeld = false;
_logLines.Clear();
RebuildLog();
- _feedbackQueue.Clear();
- _feedbackPlaying = false;
- _feedbackTime = 0f;
- _feedback.gameObject.SetActive(false);
- _flash.gameObject.SetActive(false);
- _skipFeedback.gameObject.SetActive(false);
+ _confirmationBar?.gameObject.SetActive(false);
_combatStats?.gameObject.SetActive(false);
_board3D?.Clear();
_board3D?.SetVisible(true);
+ StartBattleIntro();
_overlays?.CloseAll();
HideAbilityOverlay();
HideCardChoiceOverlay();
- _selfZones?.Update(null, self: true);
- _opponentZones?.Update(null, self: false);
- _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
+ _selfZones?.Update(null, true);
+ _opponentZones?.Update(null, false);
+ _topInfo.text = "Turn 1";
+ UiKit.MakeLiteral(_topInfo);
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
_ = LoadCatalogAsync();
@@ -361,20 +382,25 @@ namespace Vanguard.UI
_view = null;
_triggerWasPending = false;
_lastTriggerKey = null;
+ _triggerZonePlayer = -1;
+ _triggerZoneNoTrigger = false;
+ _triggerZoomStartedByEvent = false;
+ _suppressNextDamageZoom = false;
+ _vanguardRevealActive = false;
+ _introActive = false;
+ _introStage = 0;
+ _introDeadline = 0f;
_triggerSequence?.Cancel();
HideActionPrompt();
_root.gameObject.SetActive(false);
_result.gameObject.SetActive(false);
- _feedback.gameObject.SetActive(false);
- _flash.gameObject.SetActive(false);
- _skipFeedback.gameObject.SetActive(false);
+ _confirmationBar?.gameObject.SetActive(false);
_overlays?.CloseAll();
HideAbilityOverlay();
HideCardChoiceOverlay();
_cardMenu?.Hide();
_combatStats?.gameObject.SetActive(false);
_board3D?.SetVisible(false);
- ReleaseFeedbackLock();
_inputLock.ReleaseAll();
}
@@ -386,8 +412,22 @@ namespace Vanguard.UI
_concedeLabel.text = "Сдаться";
}
+ if (_vanguardRevealActive
+ && Time.unscaledTime >= _vanguardRevealUntil)
+ {
+ _vanguardRevealActive = false;
+ _inputLock.Release("vanguard-reveal");
+ }
+
+ TickIntro();
UpdateClock();
- UpdateFeedback();
+ if (_status != null
+ && _status.gameObject.activeSelf
+ && Time.unscaledTime > _statusHideAt)
+ {
+ _status.gameObject.SetActive(false);
+ }
+
_triggerSequence?.Tick(_animationConfig);
RefreshActionPrompt();
SanitizeInputLocks();
@@ -417,11 +457,10 @@ namespace Vanguard.UI
_inputLock.Release("ability");
if (_cardChoiceOverlay == null || !_cardChoiceOverlay.gameObject.activeSelf)
_inputLock.Release("card-choice");
- if (!_feedbackPlaying && _feedbackQueue.Count == 0)
- {
- _feedbackLockHeld = false;
- _inputLock.Release("feedback");
- }
+ if (!_vanguardRevealActive)
+ _inputLock.Release("vanguard-reveal");
+ if (!_introActive)
+ _inputLock.Release("battle-intro");
var summary = _inputLock.Describe();
if (!_inputLock.IsLocked)
@@ -460,12 +499,18 @@ namespace Vanguard.UI
{
_autoFinishMulliganAt = Time.unscaledTime + 0.6f;
}
- else if (Time.unscaledTime >= _autoFinishMulliganAt)
+ else if (Time.unscaledTime >= _autoFinishMulliganAt
+ && (_board3D == null
+ || _board3D.MoveAnimator == null
+ || !_board3D.MoveAnimator.IsBusy)
+ && (_board3D == null
+ || _board3D.ReturningCardCount == 0)
+ && (_board3D == null || !_board3D.IsHandDrawActive))
{
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
// After an exchange the mulligan must finish by itself;
- // feedback/overlay locks must not block that transition.
+ // other locks must not block that transition.
OnIntentClicked(finish, ignoreInputLock: true);
}
}
@@ -494,7 +539,7 @@ namespace Vanguard.UI
_autoEndBattleArmed = true;
_autoEndBattleAt = Time.unscaledTime + 1.0f;
}
- else if (Time.unscaledTime >= _autoEndBattleAt && !_feedbackPlaying)
+ else if (Time.unscaledTime >= _autoEndBattleAt)
{
_autoEndBattleArmed = false;
OnIntentClicked(endTurn);
@@ -506,62 +551,12 @@ namespace Vanguard.UI
}
}
- private void ShowFeedback(string text, Color color, float duration = 1.25f, bool flash = false)
- {
- _feedbackQueue.Enqueue(new FeedbackStep(text, color, duration, flash));
- if (!_feedbackPlaying)
- StartNextFeedback();
- }
-
- private void StartNextFeedback()
- {
- if (_feedbackQueue.Count == 0)
- {
- _feedbackPlaying = false;
- _skipFeedback.gameObject.SetActive(false);
- ReleaseFeedbackLock();
- return;
- }
-
- if (!_feedbackLockHeld)
- {
- _feedbackLockHeld = true;
- _inputLock.Acquire("feedback");
- }
-
- var step = _feedbackQueue.Dequeue();
- _feedbackPlaying = true;
- _feedback.text = step.Text;
- _feedbackColor = step.Color;
- _feedbackDuration = step.Duration;
- _feedbackTime = step.Duration;
- _feedback.gameObject.SetActive(true);
- _feedback.rectTransform.localScale = Vector3.one * 1.12f;
- _skipFeedback.gameObject.SetActive(true);
-
- if (step.Flash)
- {
- _flash.gameObject.SetActive(true);
- _flash.color = new Color(step.Color.r, step.Color.g, step.Color.b, 0.22f);
- }
- }
-
- private void ReleaseFeedbackLock()
- {
- if (!_feedbackLockHeld)
- return;
-
- _feedbackLockHeld = false;
- _inputLock.Release("feedback");
- }
-
private void OnInputLockChanged(bool locked)
{
if (_view == null)
return;
RebuildIntents(_view);
- RebuildPhaseControls(_view);
}
private void OnOverlayOpenChanged(bool open)
@@ -572,56 +567,6 @@ namespace Vanguard.UI
_inputLock.Release("overlay");
}
- private void UpdateFeedback()
- {
- if (!_feedbackPlaying)
- return;
-
- _feedbackTime -= Time.unscaledDeltaTime;
- var t = Mathf.Clamp01(_feedbackTime / Mathf.Max(0.01f, _feedbackDuration));
- var alpha = Mathf.Clamp01(t * 1.8f);
- _feedback.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha);
- _feedback.rectTransform.localScale = Vector3.one * Mathf.Lerp(1f, 1.12f, t);
-
- if (_flash != null && _flash.gameObject.activeSelf)
- _flash.color = new Color(_feedbackColor.r, _feedbackColor.g, _feedbackColor.b, alpha * 0.22f);
-
- if (_feedbackTime > 0f)
- return;
-
- _feedback.gameObject.SetActive(false);
- if (_flash != null)
- _flash.gameObject.SetActive(false);
- StartNextFeedback();
- }
-
- private void SkipFeedback()
- {
- _feedbackQueue.Clear();
- _feedbackPlaying = false;
- _feedbackTime = 0f;
- _feedback.gameObject.SetActive(false);
- _flash.gameObject.SetActive(false);
- _skipFeedback.gameObject.SetActive(false);
- ReleaseFeedbackLock();
- }
-
- private readonly struct FeedbackStep
- {
- public FeedbackStep(string text, Color color, float duration, bool flash)
- {
- Text = text;
- Color = color;
- Duration = duration;
- Flash = flash;
- }
-
- public string Text { get; }
- public Color Color { get; }
- public float Duration { get; }
- public bool Flash { get; }
- }
-
private RectTransform BuildResultPanel(Transform parent)
{
var overlay = UiKit.CreatePanel(parent, "BattleResult", new Color(0f, 0f, 0f, 0.75f));
@@ -772,7 +717,9 @@ namespace Vanguard.UI
PruneGuardSelection();
PruneAbilityCostSelection();
_triggerPowerSlot = null;
- PrepareTriggerSelection();
+ _triggerSelectionReady = false;
+ if (_triggerSequence == null || !_triggerSequence.IsBusy)
+ PrepareTriggerSelection();
if (_view.Clock != null)
{
_clockServerMs = _view.Clock.ServerTimeMs;
@@ -787,6 +734,181 @@ namespace Vanguard.UI
private bool _triggerWasPending;
private string _lastTriggerKey;
+ private int _triggerZonePlayer = -1;
+ private bool _triggerZoneNoTrigger;
+ private bool _triggerZoomStartedByEvent;
+ private bool _triggerSelectionReady;
+ private bool _suppressNextDamageZoom;
+ private bool _triggerCameraFocused;
+ private bool _vanguardRevealActive;
+ private float _vanguardRevealUntil;
+ private bool _introActive;
+ private int _introStage;
+ private float _introDeadline;
+
+ private void OnTriggerFocusChanged(bool focused)
+ {
+ _triggerCameraFocused = focused;
+ _board3D?.SetHandVisible(!focused);
+ if (focused)
+ return;
+
+ if (_triggerZonePlayer >= 0)
+ {
+ _board3D?.SetTriggerZoneMessage(_triggerZonePlayer, string.Empty);
+ _triggerZonePlayer = -1;
+ }
+
+ _triggerZoneNoTrigger = false;
+ _triggerZoomStartedByEvent = false;
+ }
+
+ private void OnTriggerZoomCompleted()
+ {
+ if (_triggerZonePlayer < 0 || _board3D == null)
+ return;
+
+ _board3D.SetTriggerZoneVisible(_triggerZonePlayer, true);
+ _board3D.SetTriggerZoneMessage(
+ _triggerZonePlayer,
+ _triggerZoneNoTrigger ? "БЕЗ ТРИГГЕРА" : string.Empty);
+
+ if (!_triggerZoneNoTrigger)
+ {
+ _board3D.RevealTriggerCard(
+ _triggerZonePlayer,
+ _animationConfig != null
+ ? _animationConfig.triggerRevealDuration
+ : 0.35f);
+ }
+ }
+
+ private void OnTriggerResolveReady()
+ {
+ if (_triggerZoneNoTrigger
+ || _triggerZonePlayer < 0
+ || _view?.Trigger == null
+ || _view.Trigger.Player != _triggerZonePlayer
+ || _view.ActivePlayer != _view.You)
+ {
+ return;
+ }
+
+ PrepareTriggerSelection();
+ if (_triggerSelectionReady)
+ {
+ RebuildIntents(_view);
+ RefreshActionPrompt();
+ }
+ }
+
+ private void StartBattleIntro()
+ {
+ if (_board3D == null || _animationConfig == null)
+ return;
+
+ _introActive = true;
+ _introStage = 0;
+ _introDeadline = Time.unscaledTime + _animationConfig.introOverheadHold;
+ _board3D.SetHandVisible(false);
+ _board3D.SetOpponentHandVisible(false);
+ _board3D.SetCameraIntro();
+ _inputLock.Acquire("battle-intro");
+ }
+
+ private void TickIntro()
+ {
+ if (!_introActive || _board3D == null || _animationConfig == null)
+ return;
+
+ var now = Time.unscaledTime;
+ if (_introStage == 0 && now >= _introDeadline)
+ {
+ _introStage = 1;
+ _board3D.MoveCameraToMain(_animationConfig.introCameraMoveDuration);
+ _introDeadline = now + _animationConfig.introCameraMoveDuration;
+ return;
+ }
+
+ if (_introStage == 1 && now >= _introDeadline)
+ {
+ _introStage = 2;
+ _introDeadline = now + _animationConfig.introMainHold;
+ return;
+ }
+
+ if (_introStage == 2 && now >= _introDeadline)
+ {
+ if (_view == null)
+ {
+ _introDeadline = now + 0.2f;
+ return;
+ }
+
+ _introStage = 3;
+ _board3D.AnimateHandDraw(_animationConfig.introDrawDuration);
+ _introDeadline = now + 0.1f;
+ return;
+ }
+
+ if (_introStage == 3
+ && now >= _introDeadline
+ && !_board3D.IsHandDrawActive
+ && (_board3D.MoveAnimator == null
+ || !_board3D.MoveAnimator.IsBusy))
+ {
+ _introActive = false;
+ _inputLock.Release("battle-intro");
+ if (_view != null)
+ {
+ RebuildIntents(_view);
+ RefreshActionPrompt();
+ }
+ }
+ }
+
+ private void BeginVanguardReveal()
+ {
+ if (_vanguardRevealActive)
+ return;
+
+ _vanguardRevealActive = true;
+ _vanguardRevealUntil = Time.unscaledTime
+ + (_animationConfig != null
+ ? _animationConfig.vanguardRevealDuration
+ : 0.8f)
+ + 0.1f;
+ _inputLock.Acquire("vanguard-reveal");
+ }
+
+ private void BeginTriggerZoneZoom(int player, bool hasTrigger, bool fromEvent)
+ {
+ if (_triggerSequence == null || _board3D == null)
+ return;
+
+ if (_triggerSequence.IsBusy && _triggerZonePlayer == player)
+ {
+ if (hasTrigger && _triggerZoneNoTrigger)
+ {
+ _triggerZoneNoTrigger = false;
+ _triggerWasPending = true;
+ }
+
+ return;
+ }
+
+ _triggerZonePlayer = player;
+ _triggerZoneNoTrigger = !hasTrigger;
+ _triggerWasPending = hasTrigger;
+ _triggerSelectionReady = false;
+ _lastTriggerKey = null;
+ _triggerZoomStartedByEvent = fromEvent;
+ _board3D.SetTriggerZoneVisible(player, false);
+ _board3D.SetTriggerZoneMessage(player, string.Empty);
+ _triggerSequence.ShowTrigger(
+ _animationConfig,
+ _board3D.TriggerCameraPoint(player));
+ }
private void SyncTriggerSequence()
{
@@ -794,23 +916,34 @@ namespace Vanguard.UI
return;
var trigger = _view.Trigger;
- if (trigger != null && _view.ActivePlayer == _view.You)
+ if (trigger != null)
{
var key = trigger.Player + ":" + trigger.PrintingId
+ ":" + _view.EventSeq;
- if (!_triggerWasPending
- || !string.Equals(_lastTriggerKey, key, StringComparison.Ordinal))
+
+ if (_triggerZoomStartedByEvent)
{
+ _triggerZoomStartedByEvent = false;
_triggerWasPending = true;
_lastTriggerKey = key;
- _triggerSequence.ShowTrigger(
- _animationConfig,
- _board3D.TriggerCameraPoint(trigger.Player));
+ return;
}
+ if (_triggerWasPending
+ && string.Equals(_lastTriggerKey, key, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ _triggerWasPending = true;
+ _lastTriggerKey = key;
+ BeginTriggerZoneZoom(trigger.Player, hasTrigger: true, fromEvent: false);
return;
}
+ if (_triggerZoomStartedByEvent)
+ _triggerZoomStartedByEvent = false;
+
if (_triggerWasPending)
{
_triggerWasPending = false;
@@ -823,16 +956,68 @@ namespace Vanguard.UI
{
if (events?.Events != null)
{
+ var batchHasDriveCheck = events.Events
+ .Any(envelope => envelope.Event is DriveCheckedView);
+ var damageZoomStarted = false;
+
foreach (var envelope in events.Events)
{
if (envelope.Event is GameFinishedView)
_finished = true;
+ if (envelope.Event is MulliganView mulligan)
+ {
+ _board3D?.QueueHandReturnToDeck(
+ mulligan.Player,
+ mulligan.Count);
+ }
+ else if (envelope.Event is CardReturnedToDeckView returned)
+ {
+ _board3D?.QueueHandReturnToDeck(
+ returned.Player,
+ 1,
+ returned.PrintingId);
+ }
+
+ if (_view?.Phase == GamePhase.Mulligan
+ && envelope.Event is PhaseChangedView phase
+ && phase.Phase != GamePhase.Mulligan)
+ {
+ BeginVanguardReveal();
+ }
+
+ if (envelope.Event is DriveCheckedView drive)
+ {
+ BeginTriggerZoneZoom(
+ drive.Player,
+ drive.Trigger != TriggerKind.None,
+ fromEvent: true);
+ }
+ else if (envelope.Event is TriggerResolvedView trigger
+ && !_triggerWasPending)
+ {
+ BeginTriggerZoneZoom(trigger.Player, hasTrigger: true, fromEvent: true);
+ if (!batchHasDriveCheck)
+ _suppressNextDamageZoom = true;
+ }
+ else if (envelope.Event is DamageTakenView damage
+ && !_suppressNextDamageZoom
+ && !damageZoomStarted)
+ {
+ BeginTriggerZoneZoom(
+ damage.Player,
+ hasTrigger: false,
+ fromEvent: true);
+ damageZoomStarted = true;
+ }
+ else if (envelope.Event is DamageTakenView)
+ {
+ _suppressNextDamageZoom = false;
+ }
+
var line = Describe(envelope.Event);
if (!string.IsNullOrWhiteSpace(line))
_logLines.Add(line);
-
- ShowFeedbackFor(envelope.Event);
}
while (_logLines.Count > 40)
@@ -864,57 +1049,6 @@ namespace Vanguard.UI
}
}
- private void ShowFeedbackFor(EventView view)
- {
- switch (view)
- {
- case AttackDeclaredView attack:
- ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot)
- + " → " + SlotName(attack.TargetSlot),
- new Color(1f, 0.82f, 0.35f), _animationConfig.attackDeclare);
- break;
- case GuardAddedView guard:
- ShowFeedback("GUARD " + CardName(guard.PrintingId)
- + " +" + guard.Shield,
- new Color(0.45f, 0.82f, 1f), _animationConfig.guard);
- break;
- case DriveCheckedView drive:
- ShowFeedback("DRIVE " + CardName(drive.PrintingId)
- + " · " + TriggerShort(drive.Trigger.ToString()),
- new Color(0.72f, 0.88f, 1f), _animationConfig.driveCheck);
- break;
- case TriggerResolvedView trigger:
- ShowFeedback("TRIGGER " + CardName(trigger.PrintingId),
- new Color(0.72f, 0.88f, 1f), _animationConfig.trigger);
- break;
- case BattleResolvedView battle:
- ShowFeedback(battle.Hit ? "HIT" : "BLOCK",
- battle.Hit ? new Color(1f, 0.45f, 0.35f) : new Color(0.45f, 0.82f, 1f),
- _animationConfig.battleResolved);
- break;
- case DamageTakenView damage:
- ShowFeedback("+" + damage.Amount + " DAMAGE",
- new Color(1f, 0.30f, 0.26f), _animationConfig.damage, flash: true);
- break;
- case HealedView:
- ShowFeedback("HEAL",
- new Color(0.45f, 0.95f, 0.62f), _animationConfig.heal, flash: true);
- break;
- case UnitRetiredView:
- ShowFeedback("RETIRE",
- new Color(1f, 0.55f, 0.30f), _animationConfig.retireDuration);
- break;
- case InterceptedView intercept:
- ShowFeedback("INTERCEPT " + CardName(intercept.PrintingId),
- new Color(0.55f, 0.82f, 1f), _animationConfig.intercept);
- break;
- case PerfectGuardView perfect:
- ShowFeedback("PERFECT GUARD " + CardName(perfect.SentinelPrintingId),
- new Color(0.60f, 0.82f, 1f), _animationConfig.perfectGuard);
- break;
- }
- }
-
private string Describe(EventView view)
{
if (view == null)
@@ -923,10 +1057,10 @@ namespace Vanguard.UI
return view switch
{
UnitRodeView ride => "Ride: " + CardName(ride.PrintingId),
- UnitCalledView call => "Call: " + CardName(call.PrintingId) + " → " + SlotName(call.Slot),
- UnitMovedView move => "Move: " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot),
+ UnitCalledView call => "Call: " + CardName(call.PrintingId) + " > " + SlotName(call.Slot),
+ UnitMovedView move => "Move: " + SlotName(move.FromSlot) + " > " + SlotName(move.ToSlot),
AttackDeclaredView attack => "Атака: " + SlotName(attack.AttackerSlot)
- + " → " + SlotName(attack.TargetSlot),
+ + " > " + SlotName(attack.TargetSlot),
GuardAddedView guard => "Guard: " + CardName(guard.PrintingId)
+ " (+" + guard.Shield + ")",
PerfectGuardView perfect => "Perfect Guard: " + CardName(perfect.SentinelPrintingId),
@@ -1189,6 +1323,7 @@ namespace Vanguard.UI
if (!result.Accepted)
{
+ _board3D?.ReturnDroppedCard();
if (intent is MulliganIntent || intent is FinishMulliganIntent)
{
_mulliganUsed = false;
@@ -1207,12 +1342,12 @@ namespace Vanguard.UI
if (intent is MulliganIntent)
{
// The exchange itself completes the mulligan; finish it
- // automatically even if a feedback/overlay lock is active.
+ // automatically even if an overlay lock is active.
_mulliganUsed = true;
_autoFinishMulligan = true;
_autoFinishMulliganAt = Time.unscaledTime + 0.3f;
if (_view != null)
- RebuildPhaseControls(_view);
+ RebuildIntents(_view);
}
SetStatus(result.Duplicate ? "Повтор уже применён." : "Принято.", Muted);
@@ -1226,6 +1361,7 @@ namespace Vanguard.UI
catch (Exception exception)
{
Debug.LogException(exception);
+ _board3D?.ReturnDroppedCard();
SetStatus("Ошибка отправки интента: " + exception.Message, new Color(1f, 0.55f, 0.45f));
}
finally
@@ -1242,8 +1378,8 @@ namespace Vanguard.UI
if (_intentPanelToggleLabel != null)
{
_intentPanelToggleLabel.text = _intentPanelVisible
- ? "Скрыть панель"
- : "Показать панель";
+ ? "Показать панель"
+ : "Скрыть панель";
UiKit.MakeLiteral(_intentPanelToggleLabel);
}
@@ -1658,19 +1794,12 @@ namespace Vanguard.UI
private void ApplyIntentPanelLayout()
{
- var shift = _intentPanelVisible ? 0f : IntentPanelShift;
- if (_phasePanel != null)
- _phasePanel.anchoredPosition = new Vector2(-468f + shift, 0f);
- if (_opponentZones?.Root != null)
- _opponentZones.Root.anchoredPosition = new Vector2(-488f + shift, -108f);
if (_board3D != null)
{
_board3D.SetHandViewportX(
- _intentPanelVisible
- ? (_board3D.Config != null
- ? _board3D.Config.handViewportX
- : 0.44f)
- : 0.5f);
+ _board3D.Config != null
+ ? _board3D.Config.handViewportX
+ : 0.44f);
}
}
@@ -1719,8 +1848,8 @@ namespace Vanguard.UI
UpdateZoneStrips();
RebuildIntents(_view);
- RebuildPhaseControls(_view);
UpdateClock();
+ UpdateTurnHud();
if (_view.IsFinished)
ShowResult(_view);
@@ -1802,6 +1931,24 @@ namespace Vanguard.UI
?.Hand ?? new List();
var legal = _view.LegalIntents ?? new List();
+ if (IsMulliganSelecting())
+ {
+ foreach (var index in _mulliganSelected)
+ {
+ if (index >= 0 && index < hand.Count)
+ state.SelectedHandInstanceIds.Add(hand[index].InstanceId);
+ }
+ }
+
+ foreach (var ride in legal.OfType())
+ {
+ foreach (var card in hand)
+ {
+ if (card.PrintingId == ride.PrintingId)
+ state.AvailableHandInstanceIds.Add(card.InstanceId);
+ }
+ }
+
if (IsGuardStep())
{
foreach (var guard in legal.OfType())
@@ -1904,7 +2051,8 @@ namespace Vanguard.UI
return new BoardArt(
sprite != null ? sprite.texture : null,
CardArt.ClanColor(clan),
- meta != null ? Loc.CardName(meta) : printingId);
+ meta != null ? Loc.CardName(meta) : printingId,
+ meta != null ? meta.Trigger : null);
}
private bool IsDamageChoice() =>
@@ -2213,7 +2361,7 @@ namespace Vanguard.UI
ClearGuardSelection();
Render(animateMoves: false);
_sending = true;
- SetStatus("Отправляем защиту…", Muted);
+ SetStatus("C", Muted);
try
{
var acceptedAll = true;
@@ -2719,7 +2867,7 @@ namespace Vanguard.UI
var items = groups
.Select(group => new CardActionMenuItem(
- "Защититься: " + CardName(group.Key),
+ "Защита" + CardName(group.Key),
() =>
{
var variants = group.ToList();
@@ -2729,7 +2877,7 @@ namespace Vanguard.UI
OnIntentClicked(variants[0]);
}))
.ToList();
- ShowChoiceMenu("Защита", items);
+ ShowChoiceMenu("Защититься: ", items);
}
private void OpenAbilitySelector(IReadOnlyList abilities)
@@ -2748,10 +2896,10 @@ namespace Vanguard.UI
var items = groups
.Select(group => new CardActionMenuItem(
- "Активировать: " + CardNameForInstance(group.Key),
+ "Способности" + CardNameForInstance(group.Key),
() => ShowAbilityVariantChoice(group.ToList())))
.ToList();
- ShowChoiceMenu("Способности", items);
+ ShowChoiceMenu("Активировать: ", items);
}
private void ShowAbilityVariantChoice(
@@ -2999,18 +3147,23 @@ namespace Vanguard.UI
private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target)
{
if (_view == null || _sending || _inputLock.IsLocked)
+ {
+ _board3D?.ReturnDroppedCard();
return;
+ }
if (IsGuardStep()
&& target.Zone == BoardZone.Guardian
&& (source.Zone == BoardZone.Hand || source.IsField))
{
ToggleGuardFromDrop(source);
+ _board3D?.ReturnDroppedCard();
return;
}
var legal = _view.LegalIntents ?? new List();
IntentPayload intent = null;
+ Debug.Log($"[Drop] source={source.Zone}:{source.Player}:{source.PrintingId} target={target.Zone}:{target.Player}:{target.Slot} phase={_view.Phase} you={_view.You} active={_view.ActivePlayer} legal={legal.Count} rides=[{string.Join(",", legal.OfType().Select(item => item.PrintingId))}]");
if (_pendingBoosterSlot >= 0 && source.IsField)
{
@@ -3036,6 +3189,7 @@ namespace Vanguard.UI
.ToList();
if (guards.Count > 1)
{
+ _board3D?.ReturnDroppedCard();
ShowGuardDiscardChoice(guards, () => Render(animateMoves: false));
return;
}
@@ -3046,6 +3200,18 @@ namespace Vanguard.UI
{
intent = legal.OfType()
.FirstOrDefault(item => item.PrintingId == source.PrintingId);
+ if (intent == null
+ && _view.Phase == GamePhase.Ride
+ && !string.IsNullOrEmpty(source.PrintingId))
+ {
+ // Сервер всё равно авторитарно проверит карту/фазу.
+ // Это спасает от расхождения legal-списка и drag-цели.
+ intent = new RideIntent
+ {
+ PrintingId = source.PrintingId,
+ };
+ Debug.Log("[Drop] fallback direct RideIntent " + source.PrintingId);
+ }
}
else if (target.Player == _view.You && target.Zone == BoardZone.RearGuard)
{
@@ -3088,6 +3254,8 @@ namespace Vanguard.UI
if (intent == null)
{
+ Debug.Log("[Drop] no intent");
+ _board3D?.ReturnDroppedCard();
SetStatus("Сюда нельзя положить эту карту.", new Color(1f, 0.55f, 0.45f));
RefreshBoardHighlights();
return;
@@ -3199,7 +3367,7 @@ namespace Vanguard.UI
_actionPrompt.anchorMin = new Vector2(0.5f, 1f);
_actionPrompt.anchorMax = new Vector2(0.5f, 1f);
_actionPrompt.pivot = new Vector2(0.5f, 1f);
- _actionPrompt.anchoredPosition = new Vector2(0f, -98f);
+ _actionPrompt.anchoredPosition = new Vector2(0f, -142f);
_actionPrompt.sizeDelta = new Vector2(760f, 72f);
var image = _actionPrompt.GetComponent();
if (image != null)
@@ -3229,7 +3397,10 @@ namespace Vanguard.UI
private void RefreshActionPrompt()
{
- if (_actionPrompt == null || _view == null || _view.IsFinished)
+ if (_introActive
+ || _actionPrompt == null
+ || _view == null
+ || _view.IsFinished)
{
HideActionPrompt();
return;
@@ -3268,10 +3439,32 @@ namespace Vanguard.UI
}
else if (_pendingIntents.Count > 0)
{
- title = "ВЫБОР ЦЕЛИ";
- hint = _pendingIntents.Any(intent => intent is ResolveTriggerIntent)
- ? "Выберите цель силы или эффекта триггера."
- : "Выберите цель на поле.";
+ if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent)
+ && _view.Trigger != null)
+ {
+ if (!_triggerSelectionReady)
+ {
+ title = "ТРИГГЕР";
+ hint = "Проверяем карту…";
+ }
+ else if (_triggerPowerSlot == null)
+ {
+ title = "ТРИГГЕР: СИЛА";
+ hint = "Выберите свою карту в RC или VC для +5000.";
+ }
+ else
+ {
+ title = "ТРИГГЕР: ЭФФЕКТ";
+ hint = TriggerEffectHint();
+ }
+ }
+ else
+ {
+ title = "ВЫБОР ЦЕЛИ";
+ hint = _view.Trigger != null
+ ? "Выберите цель силы или эффекта триггера."
+ : "Выберите цель на поле.";
+ }
}
else if (_view.Ability != null && _view.ActivePlayer == _view.You)
{
@@ -3286,7 +3479,9 @@ namespace Vanguard.UI
else if (_view.Trigger != null && _view.ActivePlayer == _view.You)
{
title = "ТРИГГЕР";
- hint = "Выберите, как применить эффект триггера.";
+ hint = _triggerSelectionReady
+ ? TriggerEffectHint()
+ : "Проверяем карту…";
}
else if (_view.Choice != null && _view.ActivePlayer == _view.You)
{
@@ -3303,7 +3498,11 @@ namespace Vanguard.UI
else if (_view.Phase == GamePhase.Mulligan)
{
title = "МУЛИГАН";
- hint = "Выберите карты в руке, затем замените их или закончите муллиган.";
+ hint = _mulliganSelected.Count == 0
+ ? "Выберите карты в руке, затем замените их или закончите муллиган."
+ : "Выбрано карт: " + _mulliganSelected.Count
+ + ". Нажмите «Заменить " + _mulliganSelected.Count
+ + " карт» или закончите муллиган.";
}
else if (_view.Phase == GamePhase.Ride)
{
@@ -3316,9 +3515,9 @@ namespace Vanguard.UI
var guardCount = _guardSelected.Count
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
hint = guardCount == 0
- ? "Кликните карты в руке или на поле для защиты."
- : "Выбрано карт: " + guardCount
- + ". Щит: +" + GuardSelectionShield() + ".";
+ ? "Выбрано карт: "
+ : ". Щит: +" + guardCount
+ + "." + GuardSelectionShield() + ".";
}
else if (_view.Phase == GamePhase.Battle)
{
@@ -3497,24 +3696,26 @@ namespace Vanguard.UI
_overlays = new OverlayNavigationService(_root, ResolveCatalogItem);
_overlays.OpenChanged += OnOverlayOpenChanged;
- _selfZones = new ZoneBlockStripView();
+ _selfZones = new PlayerStatsPanelView();
_selfZones.Build(
_root,
- "ZoneBlocksSelf",
+ "PlayerStatsSelf",
+ true,
new Vector2(0f, 0f),
new Vector2(0f, 0f),
- new Vector2(30f, 24f),
- new Vector2(360f, 108f));
+ new Vector2(24f, 24f),
+ new Vector2(360f, 86f));
_selfZones.ZoneClicked += zone => OpenZone(zone, _view?.You ?? 0);
- _opponentZones = new ZoneBlockStripView();
+ _opponentZones = new PlayerStatsPanelView();
_opponentZones.Build(
_root,
- "ZoneBlocksOpponent",
+ "PlayerStatsOpponent",
+ false,
new Vector2(1f, 1f),
new Vector2(1f, 1f),
- new Vector2(-488f, -108f),
- new Vector2(360f, 108f));
+ new Vector2(-24f, -88f),
+ new Vector2(360f, 86f));
_opponentZones.ZoneClicked += zone =>
OpenZone(zone, _view != null ? 1 - _view.You : 1);
}
@@ -3528,8 +3729,8 @@ namespace Vanguard.UI
_combatStats.anchorMin = new Vector2(0.5f, 1f);
_combatStats.anchorMax = new Vector2(0.5f, 1f);
_combatStats.pivot = new Vector2(0.5f, 1f);
- _combatStats.anchoredPosition = new Vector2(0f, -18f);
- _combatStats.sizeDelta = new Vector2(620f, 48f);
+ _combatStats.anchoredPosition = new Vector2(0f, -86f);
+ _combatStats.sizeDelta = new Vector2(620f, 46f);
var statsImage = _combatStats.GetComponent();
if (statsImage != null)
statsImage.raycastTarget = false;
@@ -3608,8 +3809,8 @@ namespace Vanguard.UI
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
var opponent = _view.Players.FirstOrDefault(player => player.Index != _view.You);
- _selfZones?.Update(self, self: true);
- _opponentZones?.Update(opponent, self: false);
+ _selfZones?.Update(self, true);
+ _opponentZones?.Update(opponent, false);
}
private void OpenZone(BoardZone zone, int playerIndex)
@@ -3665,13 +3866,22 @@ namespace Vanguard.UI
private string CardName(string printingId) =>
_catalog.TryGetValue(printingId, out var meta) ? Loc.CardName(meta) : printingId;
- private static string TriggerShort(string trigger) => trigger switch
+ private string TriggerEffectLabel() => _view?.Trigger?.Trigger switch
{
- "Critical" => "K",
- "Draw" => "D",
- "Stand" => "S",
- "Heal" => "H",
- _ => "—",
+ TriggerKind.Critical => "Крит +1",
+ TriggerKind.Stand => "Поднять юнита",
+ TriggerKind.Draw => "Взять карту",
+ TriggerKind.Heal => "Лечить",
+ _ => "эффект",
+ };
+
+ private string TriggerEffectHint() => _view?.Trigger?.Trigger switch
+ {
+ TriggerKind.Critical => "Крит +1: выберите свою карту в RC или VC.",
+ TriggerKind.Stand => "Подъём: выберите повёрнутую карту в RC.",
+ TriggerKind.Draw => "Добор: карта добавляется автоматически.",
+ TriggerKind.Heal => "Лечение: выберите карту урона, если условия позволяют.",
+ _ => "Выберите, как применить эффект триггера.",
};
private bool IsMulliganSelecting() =>
@@ -3701,11 +3911,6 @@ namespace Vanguard.UI
if (!_mulliganSelected.Add(index))
_mulliganSelected.Remove(index);
- SetStatus(
- _mulliganSelected.Count == 0
- ? "Кликните карты, которые вернутся в колоду."
- : "К обмену выбрано карт: " + _mulliganSelected.Count + ".",
- new Color(0.98f, 0.82f, 0.42f));
Render(animateMoves: false);
}
@@ -3721,7 +3926,7 @@ namespace Vanguard.UI
_autoFinishMulligan = true;
_autoFinishMulliganAt = 0f;
if (_view != null)
- RebuildPhaseControls(_view);
+ RebuildIntents(_view);
OnIntentClicked(new MulliganIntent { HandIndexes = indexes });
}
@@ -3877,18 +4082,25 @@ namespace Vanguard.UI
private void PrepareTriggerSelection()
{
if (_view?.Trigger == null || _view.ActivePlayer != _view.You)
+ {
+ _triggerSelectionReady = false;
return;
+ }
var triggers = (_view.LegalIntents ?? new List())
.OfType()
.ToList();
if (triggers.Count == 0)
+ {
+ _triggerSelectionReady = false;
return;
+ }
_pendingIntents.Clear();
_pendingIntents.AddRange(triggers);
_triggerPowerSlot = null;
- RebuildHighlights();
+ _triggerSelectionReady = true;
+ RebuildHighlights(animateMoves: true);
}
private bool HandleTriggerTarget(BoardTarget target)
@@ -3926,21 +4138,30 @@ namespace Vanguard.UI
RebuildHighlights();
RebuildIntents(_view);
SetStatus(
- "Триггер: выберите цель эффекта.",
+ TriggerEffectHint(),
new Color(0.98f, 0.82f, 0.42f));
return true;
}
var effect = triggers.FirstOrDefault(trigger => trigger.EffectTarget == target.Slot);
if (effect == null)
+ {
+ if (_view.Trigger.Trigger == TriggerKind.Stand)
+ {
+ SetStatus(
+ "Поднять можно только повёрнутую карту в RC.",
+ new Color(1f, 0.55f, 0.45f));
+ }
+
return false;
+ }
ClearSelection(rebuild: false);
OnIntentClicked(effect);
return true;
}
- private void RebuildHighlights()
+ private void RebuildHighlights(bool animateMoves = false)
{
_highlightSlots.Clear();
@@ -3957,7 +4178,7 @@ namespace Vanguard.UI
_highlightSlots.Add(new BoardSlotKey(_view.You, slot));
}
- RefreshBoardHighlights();
+ RefreshBoardHighlights(animateMoves);
return;
}
@@ -3970,13 +4191,13 @@ namespace Vanguard.UI
_highlightSlots.Add(new BoardSlotKey(_view.You, attack.BoosterSlot));
}
- RefreshBoardHighlights();
+ RefreshBoardHighlights(animateMoves);
}
- private void RefreshBoardHighlights()
+ private void RefreshBoardHighlights(bool animateMoves = false)
{
if (_view != null && _board3D != null)
- _board3D.Render(_view, BuildBoardRenderState());
+ _board3D.Render(_view, BuildBoardRenderState(), animateMoves);
}
private bool TryGetSource(IntentPayload intent, out BoardTarget source)
@@ -4115,7 +4336,18 @@ namespace Vanguard.UI
private void RebuildIntents(MatchView view)
{
+ if (_intents == null || view == null)
+ return;
+
ClearChildren(_intents);
+ if (_introActive)
+ {
+ _confirmationBar?.gameObject.SetActive(false);
+ return;
+ }
+
+ if (_confirmationBar != null)
+ _confirmationBar.gameObject.SetActive(true);
if (_discardChoiceActive)
{
@@ -4124,22 +4356,18 @@ namespace Vanguard.UI
}
var legal = view.LegalIntents ?? new List();
- var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
- var abilities = playable.OfType().ToList();
- var board = playable
- .Where(intent => IsBoardIntent(intent)
- && intent is not ActivateAbilityIntent
- && intent is not GuardIntent
- && intent is not RideIntent)
+ var playable = legal
+ .Where(intent => intent is not ConcedeIntent)
+ .Where(intent => !(intent is FinishMulliganIntent && _mulliganUsed))
.ToList();
var global = playable
.Where(intent => !IsBoardIntent(intent)
- && !IsPhaseIntent(intent)
&& intent is not PassGuardIntent
&& intent is not ActivateAbilityIntent
&& intent is not ResolveAbilityIntent
&& !(intent is ChooseIntent
- && (IsRearGuardChoice() || IsHandChoice() || IsCardChoice())))
+ && (IsRearGuardChoice() || IsHandChoice() || IsCardChoice()))
+ && !(intent is EndTurnIntent && view.Phase == GamePhase.Main))
.ToList();
if (_pendingIntents.Count > 0)
@@ -4149,8 +4377,8 @@ namespace Vanguard.UI
"TargetPrompt",
_pendingIntents.Any(intent => intent is ResolveTriggerIntent)
? (_triggerPowerSlot == null
- ? "Триггер: выберите цель силы."
- : "Триггер: выберите цель эффекта.")
+ ? "Триггер: выберите свою карту в RC или VC для +5000."
+ : TriggerEffectHint())
: _pendingBoosterSlot >= 0
? "Поддержка: " + CardName(
CardAt(_view.You, _pendingBoosterSlot)?.PrintingId ?? string.Empty)
@@ -4174,27 +4402,17 @@ namespace Vanguard.UI
if (playable.Count == 0)
{
- var waiting = UiKit.CreateText(
- _intents,
- "Waiting",
- view.IsFinished ? "Матч завершён." : "Сейчас решает противник…",
- 18,
- Muted);
- UiKit.SetPreferredHeight(waiting, 110f);
+ _confirmationBar?.gameObject.SetActive(false);
return;
}
- if (IsMulliganSelecting())
+ if (global.Count == 0
+ && !IsAbilityDamageTargeting()
+ && !IsGuardStep()
+ && _pendingAbilityCost == null)
{
- var mulliganHint = UiKit.CreateText(
- _intents,
- "MulliganHint",
- "Кликните карты, которые вернутся в колоду.",
- 15,
- new Color(0.72f, 0.80f, 0.94f),
- TextAnchor.UpperLeft);
- mulliganHint.horizontalOverflow = HorizontalWrapMode.Wrap;
- UiKit.SetPreferredHeight(mulliganHint, 34f);
+ _confirmationBar?.gameObject.SetActive(false);
+ return;
}
if (IsAbilityDamageTargeting())
@@ -4210,44 +4428,70 @@ namespace Vanguard.UI
UiKit.SetPreferredHeight(abilityHint, 34f);
}
- if (board.Count > 0)
- {
- var hint = UiKit.CreateText(
- _intents,
- "BoardHint",
- "Нажмите на карту в руке или на поле.\nДопустимые цели подсветятся.",
- 15,
- new Color(0.72f, 0.80f, 0.94f),
- TextAnchor.UpperLeft);
- hint.horizontalOverflow = HorizontalWrapMode.Wrap;
- UiKit.SetPreferredHeight(hint, 52f);
+ if (IsGuardStep())
+ AddGuardConfirmationButton();
- var toggle = UiKit.CreateButton(
- _intents,
- "ToggleAllIntents",
- _showAllIntents ? "Скрыть все действия" : "Показать все действия",
- () =>
- {
- _showAllIntents = !_showAllIntents;
- RebuildIntents(_view);
- },
- 15);
- UiKit.SetPreferredHeight(toggle, 38f);
- }
-
- if (abilities.Count > 0 && _pendingAbilityCost == null)
- AddAbilityButton(abilities);
+ if (_pendingAbilityCost != null)
+ AddAbilityCostConfirmationButton();
foreach (var intent in global)
- AddIntentButton(intent);
-
- if (_showAllIntents)
{
- foreach (var intent in board)
- AddIntentButton(intent);
+ if (intent is FinishMulliganIntent && _mulliganSelected.Count > 0)
+ {
+ var exchange = UiKit.CreateButton(
+ _intents,
+ "MulliganExchange",
+ "Заменить " + _mulliganSelected.Count + " карт",
+ SendMulligan,
+ 15);
+ exchange.interactable = !_inputLock.IsLocked && !_sending;
+ UiKit.SetPreferredHeight(exchange, 42f);
+ continue;
+ }
+
+ AddIntentButton(intent);
}
}
+ private void AddGuardConfirmationButton()
+ {
+ var count = _guardSelected.Count
+ + (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
+ var button = UiKit.CreateButton(
+ _intents,
+ "Phase_Guard",
+ count == 0 ? "Не защищаться" : "Защищаться",
+ () =>
+ {
+ if (_guardSelected.Count == 0
+ && _guardSelectedInterceptSlot < 0)
+ {
+ OnIntentClicked(new PassGuardIntent());
+ }
+ else
+ {
+ ConfirmGuardSelection();
+ }
+ },
+ 15);
+ button.interactable = !_inputLock.IsLocked && !_sending;
+ UiKit.SetPreferredHeight(button, 42f);
+ }
+
+ private void AddAbilityCostConfirmationButton()
+ {
+ var button = UiKit.CreateButton(
+ _intents,
+ "Phase_ConfirmAbilityCost",
+ "Подтвердить оплату",
+ ConfirmAbilityCost,
+ 15);
+ button.interactable = !_inputLock.IsLocked
+ && !_sending
+ && IsAbilityCostReady();
+ UiKit.SetPreferredHeight(button, 42f);
+ }
+
private void AddIntentButton(IntentPayload intent)
{
var captured = intent;
@@ -4281,7 +4525,19 @@ namespace Vanguard.UI
private void UpdateClock()
{
- if (_view?.Clock == null || !_view.Clock.IsRunning)
+ if (_view?.Clock == null)
+ {
+ _clock.text = "—";
+ _bank.text = "Банк —";
+ return;
+ }
+
+ var bank = _view.Clock.DecisionPlayer == _view.You
+ ? _view.Clock.Bank0Ms
+ : _view.Clock.Bank1Ms;
+ _bank.text = "Банк " + Format(bank);
+
+ if (!_view.Clock.IsRunning)
{
_clock.text = "—";
return;
@@ -4290,11 +4546,49 @@ namespace Vanguard.UI
var localNow = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var serverNow = _clockServerMs + (localNow - _clockReceivedMs);
var remaining = Math.Max(0, _view.Clock.TurnDeadlineMs - serverNow);
- var bank = _view.Clock.DecisionPlayer == _view.You
- ? _view.Clock.Bank0Ms
- : _view.Clock.Bank1Ms;
+ _clock.text = Format(remaining);
+ }
- _clock.text = Format(remaining) + " · банк " + Format(bank);
+ private void UpdateTurnHud()
+ {
+ if (_topInfo == null)
+ return;
+
+ if (_view == null)
+ {
+ _topInfo.text = "Turn —";
+ UiKit.MakeLiteral(_topInfo);
+ return;
+ }
+
+ var phases = new[]
+ {
+ (Label: "Standby", Phase: GamePhase.Stand),
+ (Label: "Main", Phase: GamePhase.Main),
+ (Label: "Battle", Phase: GamePhase.Battle),
+ (Label: "End", Phase: GamePhase.End),
+ };
+ var stepper = string.Join(
+ " › ",
+ phases.Select(item => item.Phase == _view.Phase
+ ? "" + item.Label + ""
+ : "" + item.Label + ""));
+
+ var step = _view.Step switch
+ {
+ GameStep.Guard => " · Защита",
+ GameStep.Drive => " · Drive",
+ GameStep.Trigger => " · Триггер",
+ GameStep.Ability => " · Способность",
+ GameStep.Choice => " · Выбор",
+ _ => string.Empty,
+ };
+
+ var prefix = _view.Phase == GamePhase.Mulligan
+ ? "Муллиган"
+ : "Turn " + _view.TurnNumber + " " + stepper;
+ _topInfo.text = prefix + step;
+ UiKit.MakeLiteral(_topInfo);
}
private static string Format(long milliseconds)
@@ -4329,22 +4623,25 @@ namespace Vanguard.UI
{
switch (intent)
{
- case FinishMulliganIntent _: return "Закончить маллиган";
+ case FinishMulliganIntent _: return "Закончить муллиган";
case MulliganIntent mulligan: return "Маллиган: " + mulligan.HandIndexes.Count + " карт";
case SkipRideIntent _: return "Пропустить Ride";
case RideIntent ride: return "Ride: " + CardName(ride.PrintingId);
- case CallIntent call: return "Call " + CardName(call.PrintingId) + " → " + SlotName(call.Slot);
- case MoveIntent move: return "Move " + SlotName(move.FromSlot) + " → " + SlotName(move.ToSlot);
- case EnterBattleIntent _: return "В фазу боя";
+ case CallIntent call: return "Call " + CardName(call.PrintingId) + " > " + SlotName(call.Slot);
+ case MoveIntent move: return "Move " + SlotName(move.FromSlot) + " > " + SlotName(move.ToSlot);
+ case EnterBattleIntent _: return "Боевая фаза";
case AttackIntent attack:
- return "Атака " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot)
+ return "Атака " + SlotName(attack.AttackerSlot) + " > " + SlotName(attack.TargetSlot)
+ (attack.BoosterSlot >= 0 ? " + boost" : string.Empty);
case GuardIntent guard: return "Guard: " + CardName(guard.PrintingId);
case InterceptIntent intercept: return "Intercept: " + SlotName(intercept.Slot);
case PassGuardIntent _: return "Не защищаться";
case ResolveTriggerIntent trigger:
- return "Триггер: сила " + SlotName(trigger.PowerTarget)
- + ", эффект " + SlotName(trigger.EffectTarget);
+ if (trigger.EffectTarget < 0)
+ return "Триггер: +5000 → " + SlotName(trigger.PowerTarget);
+ return "Триггер: +5000 → " + SlotName(trigger.PowerTarget)
+ + ", " + TriggerEffectLabel() + " > "
+ + SlotName(trigger.EffectTarget);
case SelectTriggerIntent select: return "Выбрать триггер #" + select.Index;
case ResolveAbilityIntent ability:
return ability.Accept ? "Применить способность" : "Пропустить способность";
@@ -4371,8 +4668,14 @@ namespace Vanguard.UI
private void SetStatus(string text, Color color)
{
- _status.text = text ?? string.Empty;
+ if (_status == null)
+ return;
+
+ var value = text ?? string.Empty;
+ _status.text = value;
_status.color = color;
+ _status.gameObject.SetActive(!string.IsNullOrWhiteSpace(value));
+ _statusHideAt = Time.unscaledTime + 4f;
}
private readonly struct BoardTarget
@@ -4413,4 +4716,4 @@ namespace Vanguard.UI
}
}
}
-}
\ No newline at end of file
+}
diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays/PlayerStatsPanelView.cs b/client/Assets/Vanguard/Runtime/UI/Overlays/PlayerStatsPanelView.cs
new file mode 100644
index 0000000..589bc52
--- /dev/null
+++ b/client/Assets/Vanguard/Runtime/UI/Overlays/PlayerStatsPanelView.cs
@@ -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
+{
+ ///
+ /// Компактная панель игрока: Колода, Душа, Рука, Сброс.
+ /// Все блоки одинаковые; кликабельны только Душа и Сброс.
+ ///
+ public sealed class PlayerStatsPanelView
+ {
+ private sealed class StatEntry
+ {
+ public BoardZone Zone;
+ public Text Value;
+ }
+
+ private readonly List _stats = new List();
+ private RectTransform _root;
+ private Text _header;
+
+ public RectTransform Root => _root;
+ public event Action 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();
+ 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();
+ 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