From f2a01bae16ac699240f63b47aa36bab1353f3d5e Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Thu, 22 Oct 2026 18:40:31 +0500 Subject: [PATCH] Pin hand to camera view and move camera closer --- client/Assets/Vanguard/Editor/SceneSmoke.cs | 40 +++++- .../Vanguard/Runtime/App/SceneEnvironment.cs | 4 +- .../Board3D/Config/PlaymatLayoutConfig.asset | 10 +- .../Board3D/Config/PlaymatLayoutConfig.cs | 22 ++- .../Vanguard/Runtime/Board3D/View/Card3D.cs | 5 +- .../Runtime/Board3D/View/GameBoard3D.cs | 130 +++++++++++++++++- .../Vanguard/Tests/Editor/GameBoard3DTests.cs | 47 +++++++ docs/HANDOFF.md | 12 +- docs/SESSION-STATE.md | 11 +- 9 files changed, 259 insertions(+), 22 deletions(-) diff --git a/client/Assets/Vanguard/Editor/SceneSmoke.cs b/client/Assets/Vanguard/Editor/SceneSmoke.cs index 280f7f8..76d226b 100644 --- a/client/Assets/Vanguard/Editor/SceneSmoke.cs +++ b/client/Assets/Vanguard/Editor/SceneSmoke.cs @@ -53,11 +53,49 @@ namespace Vanguard.Editor var board = UnityEngine.Object.FindAnyObjectByType(); var cards = UnityEngine.Object.FindObjectsByType( FindObjectsInactive.Exclude); - var success = board != null && cards.Length >= 10; + var camera = Camera.main; + var handVisible = 0; + var handCards = 0; + var fieldVisible = false; + if (camera != null) + { + foreach (var card in cards) + { + var viewport = camera.WorldToViewportPoint(card.transform.position); + if (card.Ref.Zone == Vanguard.Board3D.BoardZone.Hand) + { + handCards++; + if (viewport.z > 0f + && viewport.x > 0.02f + && viewport.x < 0.98f + && viewport.y > 0.01f + && viewport.y < 0.55f) + { + handVisible++; + } + } + else if (card.Ref.Zone == Vanguard.Board3D.BoardZone.Vanguard + && card.Ref.Player == 0 + && viewport.z > 0f + && viewport.y > 0.05f + && viewport.y < 0.75f) + { + fieldVisible = true; + } + } + } + + var success = board != null + && cards.Length >= 10 + && handCards > 0 + && handVisible == handCards + && fieldVisible; Debug.Log( "PLAYMAT_SMOKE success=" + success + " board=" + (board != null) + " cards=" + cards.Length + + " hand=" + handVisible + "/" + handCards + + " fieldVisible=" + fieldVisible + " error=" + VanguardApp.LastInitializationError); EditorApplication.Exit(success ? 0 : 1); diff --git a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs index efe1f1b..8459a8a 100644 --- a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs +++ b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs @@ -54,12 +54,12 @@ namespace Vanguard.App camera.fieldOfView = theme == SceneTheme.Battle ? 50f : 52f; camera.transform.position = theme switch { - SceneTheme.Battle => new Vector3(0f, 8.7f, -12.4f), + SceneTheme.Battle => new Vector3(0f, 6.9f, -9.8f), SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f), _ => new Vector3(0f, 3.2f, -10f), }; camera.transform.rotation = theme == SceneTheme.Battle - ? Quaternion.Euler(34.4f, 0f, 0f) + ? Quaternion.Euler(35.2f, 0f, 0f) : Quaternion.Euler(8f, 0f, 0f); } diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset index 90d615b..f19a043 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset @@ -36,13 +36,19 @@ MonoBehaviour: handSpacing: 0.42 handMaxWidth: 7.4 handArc: 0.035 + handViewportY: 0.16 + handViewDistance: 3.6 + handViewMaxWidth: 2 + handViewScale: 0.9 + handViewRoll: 3.2 + handViewArc: 0.02 guardianRowGap: 0.55 guardianSpacing: 0.44 guardianMaxWidth: 3.6 damageX: 3.5 pileX: 3.55 - cameraPosition: {x: 0, y: 8.7, z: -12.4} - cameraTarget: {x: 0, y: 0, z: 0.3} + cameraPosition: {x: 0, y: 6.9, z: -9.8} + cameraTarget: {x: 0, y: 0, z: -0.15} cameraFov: 50 orbitYawLimit: 32 orbitPitchMin: 20 diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs index 21903fc..7af84eb 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs @@ -41,7 +41,7 @@ namespace Vanguard.Board3D public float smoothRotateSpeed = 14f; public float handHoverLift = 0.08f; - [Header("Рука")] + [Header("Рука на столе (fallback без камеры)")] public float handCenterZ = -5.3f; public float handHeight = 1.12f; [Range(0f, 85f)] public float handTilt = 58f; @@ -49,6 +49,16 @@ namespace Vanguard.Board3D public float handMaxWidth = 7.4f; public float handArc = 0.035f; + [Header("Рука в поле зрения")] + [Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")] + [Range(0.05f, 0.45f)] public float handViewportY = 0.16f; + [Tooltip("Расстояние от камеры до веера руки, метры.")] + public float handViewDistance = 3.6f; + public float handViewMaxWidth = 2.0f; + public float handViewScale = 0.9f; + public float handViewRoll = 3.2f; + public float handViewArc = 0.02f; + [Header("Круг защиты")] public float guardianRowGap = 0.55f; public float guardianSpacing = 0.44f; @@ -61,8 +71,8 @@ namespace Vanguard.Board3D public float pileX = 3.55f; [Header("Камера")] - public Vector3 cameraPosition = new Vector3(0f, 8.7f, -12.4f); - public Vector3 cameraTarget = new Vector3(0f, 0f, 0.3f); + public Vector3 cameraPosition = new Vector3(0f, 6.9f, -9.8f); + public Vector3 cameraTarget = new Vector3(0f, 0f, -0.15f); public float cameraFov = 50f; public float orbitYawLimit = 32f; public float orbitPitchMin = 20f; @@ -131,6 +141,12 @@ namespace Vanguard.Board3D snapRadius = Mathf.Clamp(snapRadius, 0.2f, 1.5f); handMaxWidth = Mathf.Max(1f, handMaxWidth); guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth); + handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f); + handViewDistance = Mathf.Max(1f, handViewDistance); + handViewMaxWidth = Mathf.Max(0.5f, handViewMaxWidth); + handViewScale = Mathf.Clamp(handViewScale, 0.3f, 1.5f); + handViewRoll = Mathf.Clamp(handViewRoll, 0f, 20f); + handViewArc = Mathf.Max(0f, handViewArc); orbitPitchMax = Mathf.Clamp(orbitPitchMax, orbitPitchMin + 1f, 85f); zoomMax = Mathf.Max(zoomMin + 0.5f, zoomMax); } diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs index db97989..b7e8c1c 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs @@ -149,11 +149,12 @@ namespace Vanguard.Board3D bool faceUp, bool rested, bool immediate, - bool damageLayout = false) + bool damageLayout = false, + float scale = 1f) { SetState(baseRotation, faceUp, rested, damageLayout); _targetPosition = position; - _targetScale = Vector3.one; + _targetScale = Vector3.one * Mathf.Max(0.05f, scale); _immediate = immediate; if (_dragging) diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index 4845365..79ee3ff 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -28,6 +28,7 @@ namespace Vanguard.Board3D private readonly BoardZoneView[] _damageZones = new BoardZoneView[2]; private readonly HashSet _activeHighlights = new HashSet(); private readonly HashSet _validDropSlots = new HashSet(); + private readonly List _handBindings = new List(); private bool _guardianDropAllowed; private PlaymatLayoutConfig _config; @@ -150,6 +151,7 @@ namespace Vanguard.Board3D { CancelPointer(); _hasRendered = false; + _handBindings.Clear(); } else { @@ -176,7 +178,10 @@ namespace Vanguard.Board3D SetSelfPlayer(view.You); _view = view; state ??= new BoardRenderState(); + if (_camera == null) + _camera = Camera.main; + _handBindings.Clear(); var specs = new List(40); BuildSpecs(view, state, specs); @@ -203,8 +208,17 @@ namespace Vanguard.Board3D spec.FaceUp, spec.Rested, immediateAll || changed, - spec.DamageLayout); + spec.DamageLayout, + spec.Scale); card.SetHighlight(spec.Highlight); + + if (spec.IsHand) + { + _handBindings.Add(new HandCardBinding( + card, + spec.HandIndex, + spec.HandCount)); + } } if (_cards.Count > 0) @@ -228,6 +242,7 @@ namespace Vanguard.Board3D public void Clear() { ClearCards(); + _handBindings.Clear(); _view = null; _hasRendered = false; } @@ -597,7 +612,7 @@ namespace Vanguard.Board3D for (var i = 0; i < count; i++) { var card = self.Hand[i]; - var pose = _layout.GetHandPose(i, count); + GetHandViewPose(i, count, out var position, out var rotation, out var scale); Color? highlight = null; if (state.SelectedHandInstanceIds.Contains(card.InstanceId)) highlight = _config.selectedColor; @@ -609,15 +624,99 @@ namespace Vanguard.Board3D Key = "H:" + card.InstanceId, Ref = BoardCardRef.Hand(self.Index, i, card), Art = ResolveArt(card.PrintingId), - Position = pose.Position, - BaseRotation = Quaternion.Euler(_config.handTilt, pose.Yaw, 0f), + Position = position, + BaseRotation = rotation, FaceUp = true, Rested = false, + Scale = scale, + IsHand = true, + HandIndex = i, + HandCount = count, Highlight = highlight, }); } } + /// + /// Рука висит в поле зрения камеры снизу по центру и всегда смотрит + /// на игрока. При отсутствии камеры используется старый настольный + /// вариант — для EditMode-проверок. + /// + private void GetHandViewPose( + 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.GetHandPose(index, count); + position = fallback.Position; + rotation = Quaternion.Euler(_config.handTilt, fallback.Yaw, 0f); + scale = 1f; + return; + } + + var safeCount = Mathf.Max(1, count); + var step = _config.handSpacing; + if (safeCount > 1) + step = Mathf.Min(step, _config.handViewMaxWidth / (safeCount - 1)); + + var offset = index - (safeCount - 1) * 0.5f; + var halfHeight = Mathf.Tan( + _camera.fieldOfView * 0.5f * Mathf.Deg2Rad) * _config.handViewDistance; + var x = 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; + + position = _camera.transform.TransformPoint(new Vector3(x, y, z)); + var roll = -offset * _config.handViewRoll; + rotation = _camera.transform.rotation + * Quaternion.Euler(0f, 0f, roll) + * Quaternion.Euler(-90f, 0f, 0f); + scale = _config.handViewScale; + } + + private void UpdateHandView() + { + if (_handBindings.Count == 0) + return; + + if (_camera == null) + { + _camera = Camera.main; + if (_camera == null) + return; + } + + foreach (var binding in _handBindings) + { + if (binding.Card == null || binding.Card.IsDragging) + continue; + + GetHandViewPose( + binding.Index, + binding.Count, + out var position, + out var rotation, + out var scale); + binding.Card.SetPose( + position, + rotation, + true, + false, + false, + false, + scale); + } + } + private void UpdatePiles(MatchView view) { var self = view.Players.FirstOrDefault(player => player.Index == view.You); @@ -717,6 +816,8 @@ namespace Vanguard.Board3D private void Update() { + UpdateHandView(); + var delta = Time.unscaledDeltaTime; foreach (var card in _cards.Values) { @@ -909,6 +1010,9 @@ namespace Vanguard.Board3D var targetValue = target.Value; 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 @@ -1120,6 +1224,20 @@ namespace Vanguard.Board3D } } + private sealed class HandCardBinding + { + public HandCardBinding(Card3D card, int index, int count) + { + Card = card; + Index = index; + Count = count; + } + + public Card3D Card { get; } + public int Index { get; } + public int Count { get; } + } + private sealed class CardSpec { public string Key; @@ -1130,6 +1248,10 @@ namespace Vanguard.Board3D public bool FaceUp; public bool Rested; public bool DamageLayout; + public float Scale = 1f; + public bool IsHand; + public int HandIndex; + public int HandCount; public Color? Highlight; } } diff --git a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs index 6ea3bfd..7206539 100644 --- a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs @@ -67,6 +67,53 @@ namespace Vanguard.Tests } } + [Test] + public void Hand_Is_Anchored_To_Camera_Bottom_Center_And_Faces_Player() + { + var config = PlaymatLayoutConfig.CreateDefault(); + var cameraGo = new GameObject("Main Camera", typeof(Camera)) + { + tag = "MainCamera", + }; + var camera = cameraGo.GetComponent(); + camera.fieldOfView = config.cameraFov; + camera.transform.position = config.cameraPosition; + camera.transform.rotation = Quaternion.LookRotation( + config.cameraTarget - config.cameraPosition, + Vector3.up); + + var board = GameBoard3D.Create(null, config); + try + { + board.SetVisible(true); + board.Render(BuildView(you: 0), new BoardRenderState()); + + var hand = ActiveCards() + .Where(card => card.Ref.Zone == BoardZone.Hand) + .ToList(); + Assert.AreEqual(2, hand.Count); + + foreach (var card in hand) + { + var viewport = camera.WorldToViewportPoint(card.transform.position); + Assert.Greater(viewport.z, 0f, "Hand card must be in front of the camera"); + Assert.That(viewport.x, Is.InRange(0.15f, 0.85f)); + Assert.That(viewport.y, Is.InRange(0.01f, 0.55f)); + + var toCamera = (camera.transform.position - card.transform.position).normalized; + Assert.Greater( + Vector3.Dot(card.transform.up, toCamera), + 0.8f, + "Hand card must face the camera"); + } + } + finally + { + Object.DestroyImmediate(board.gameObject); + Object.DestroyImmediate(cameraGo); + } + } + [Test] public void Pool_Reuses_Cards_Between_Snapshots() { diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 0578dda..21422db 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -854,15 +854,19 @@ Battle; Boot и общие сервисы остаются персистент - `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики, drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и индикатором точки броска. Ошибка интента возвращает карту на место. -- Камера: `BoardCameraRig` — ПКМ орбита (±32°), СКМ панорама, колесо зум +- Камера приближена: позиция `(0, 6.9, -9.8)`, цель `(0, 0, -0.15)`, FOV 50. + Рука не лежит на столе, а закреплена в экране снизу по центру + (`handViewportY`, `handViewDistance`) и всегда развёрнута лицом к игроку; + при орбите камеры веер следует за полем зрения, драг из руки уходит на стол. +- `BoardCameraRig` — ПКМ орбита (±32°), СКМ панорама, колесо зум (9.5–16); ограничения в конфиге. - Душа: клик по стопке или кнопка «Душа: n» в верхней панели открывает оверлей со списком карт. - Песочница: `Assets/Vanguard/Scenes/GameBoard3D.unity` + `PlaymatSandbox`; собирается `Vanguard → Rebuild Playmat Sandbox`, проверяется `SceneSmoke.RunPlaymat`. -- Проверки: EditMode **17/17** (геометрия, зеркальность, правила, рендер - 3D-карт, пул), `SCENE_SMOKE success=True`, - `PLAYMAT_SMOKE success=True cards=20`. +- Проверки: EditMode **18/18** (геометрия, зеркальность, правила, рендер + 3D-карт, пул, рука в поле зрения), `SCENE_SMOKE success=True`, + `PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`. - Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены; модели карт/стола — процедурные примитивы. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index dabd02d..410ff51 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -65,7 +65,9 @@ остаются uGUI. Карты — тонкие 3D-объекты с артом, рубашкой, торцом и коллайдером. Rest (атака/поддержка) — поворот по часовой стрелке вокруг Y, урон лежит на боку против часовой; закрытость — переворот вокруг X. - Драг-н-дроп на плоскость стола с подсветкой целей и плавным снапом. + Рука закреплена в поле зрения камеры снизу по центру и всегда смотрит + на игрока, а не лежит на столе. Драг-н-дроп на плоскость стола с + подсветкой целей и плавным снапом; камера приближена. - Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у противника зеркально; между полями общий круг защиты; душа — стопка под V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу, @@ -131,10 +133,11 @@ curl http://127.0.0.1:18080/health -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **17/17** EditMode (сборка всех экранов, импорт арта, +Клиентские проверки: **18/18** EditMode (сборка всех экранов, импорт арта, перевод статических/динамических строк, live-переключение языка, рендер -3D-доски с уроном и отдыхающей картой, геометрия поля и правила -перемещения), `SCENE_SMOKE success=True`, `PLAYMAT_SMOKE success=True cards=20`. +3D-доски с уроном и отдыхающей картой, геометрия поля, правила +перемещения, рука в поле зрения камеры), `SCENE_SMOKE success=True`, +`PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`. `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены: