From 26934c92134c32c13355bcb249b1402adc559ea4 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 30 Oct 2026 02:34:36 +0500 Subject: [PATCH] =?UTF-8?q?client/server:=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=203D-=D0=BF=D0=BE=D0=BB=D1=8F,=20HUD=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Board3D: CardMoveAnimator, Card3D, GameBoard3D, раскладка, слоты, меши - Battle: AnimationConfig, CameraDirector, TriggerCheckSequence - UI: BattleView, новый PlayerStatsPanelView - Settings/ProjectSettings: URP-профиль и настройки проекта - Rules/Shared: колонки Tejas и проекция матча - тесты: GameBoard3D, ClientSmoke; док ANIMATION-INVENTORY --- client/Assets/Settings/PC_RPAsset.asset | 30 +- ...niversalRenderPipelineGlobalSettings.asset | 17 +- .../Vanguard/Resources/AnimationConfig.asset | 22 +- .../Assets/Vanguard/Resources/Overlays.meta | 8 + .../Runtime/Battle/AnimationConfig.cs | 36 +- .../Vanguard/Runtime/Battle/CameraDirector.cs | 35 + .../Runtime/Battle/TriggerCheckSequence.cs | 62 +- .../Board3D/Animation/CardMoveAnimator.cs | 95 +- .../Board3D/Config/PlaymatLayoutConfig.asset | 26 +- .../Board3D/Config/PlaymatLayoutConfig.cs | 37 +- .../Runtime/Board3D/Data/BoardTypes.cs | 8 +- .../Runtime/Board3D/Logic/PlaymatLayout.cs | 17 +- .../Runtime/Board3D/View/BoardMeshLibrary.cs | 24 +- .../Runtime/Board3D/View/BoardSlotView.cs | 30 + .../Vanguard/Runtime/Board3D/View/Card3D.cs | 208 ++- .../Runtime/Board3D/View/GameBoard3D.cs | 1384 ++++++++++++++++- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 1121 ++++++++----- .../UI/Overlays/PlayerStatsPanelView.cs | 152 ++ .../UI/Overlays/PlayerStatsPanelView.cs.meta | 2 + .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 56 +- .../Vanguard/Tests/Editor/GameBoard3DTests.cs | 768 ++++++++- client/ProjectSettings/GraphicsSettings.asset | 15 + .../UnityConnectSettings.asset | 12 +- docs/Development/ANIMATION-INVENTORY.md | 113 ++ .../src/Vanguard.Rules/Engine/GameEngine.cs | 194 ++- .../Projection/MatchProjection.cs | 2 +- .../Vanguard.Rules.Tests/GameEngineTests.cs | 124 +- 27 files changed, 3962 insertions(+), 636 deletions(-) create mode 100644 client/Assets/Vanguard/Resources/Overlays.meta create mode 100644 client/Assets/Vanguard/Runtime/UI/Overlays/PlayerStatsPanelView.cs create mode 100644 client/Assets/Vanguard/Runtime/UI/Overlays/PlayerStatsPanelView.cs.meta create mode 100644 docs/Development/ANIMATION-INVENTORY.md 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