Pin hand to camera view and move camera closer
CI / test (push) Successful in 2m13s Details

This commit is contained in:
MpakobecEx 2026-10-22 18:40:31 +05:00
parent c2489b5452
commit f2a01bae16
9 changed files with 259 additions and 22 deletions

View File

@ -53,11 +53,49 @@ namespace Vanguard.Editor
var board = UnityEngine.Object.FindAnyObjectByType<Vanguard.Board3D.GameBoard3D>(); var board = UnityEngine.Object.FindAnyObjectByType<Vanguard.Board3D.GameBoard3D>();
var cards = UnityEngine.Object.FindObjectsByType<Vanguard.Board3D.Card3D>( var cards = UnityEngine.Object.FindObjectsByType<Vanguard.Board3D.Card3D>(
FindObjectsInactive.Exclude); 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( Debug.Log(
"PLAYMAT_SMOKE success=" + success "PLAYMAT_SMOKE success=" + success
+ " board=" + (board != null) + " board=" + (board != null)
+ " cards=" + cards.Length + " cards=" + cards.Length
+ " hand=" + handVisible + "/" + handCards
+ " fieldVisible=" + fieldVisible
+ " error=" + VanguardApp.LastInitializationError); + " error=" + VanguardApp.LastInitializationError);
EditorApplication.Exit(success ? 0 : 1); EditorApplication.Exit(success ? 0 : 1);

View File

@ -54,12 +54,12 @@ namespace Vanguard.App
camera.fieldOfView = theme == SceneTheme.Battle ? 50f : 52f; camera.fieldOfView = theme == SceneTheme.Battle ? 50f : 52f;
camera.transform.position = theme switch 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), SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f),
_ => new Vector3(0f, 3.2f, -10f), _ => new Vector3(0f, 3.2f, -10f),
}; };
camera.transform.rotation = theme == SceneTheme.Battle camera.transform.rotation = theme == SceneTheme.Battle
? Quaternion.Euler(34.4f, 0f, 0f) ? Quaternion.Euler(35.2f, 0f, 0f)
: Quaternion.Euler(8f, 0f, 0f); : Quaternion.Euler(8f, 0f, 0f);
} }

View File

@ -36,13 +36,19 @@ MonoBehaviour:
handSpacing: 0.42 handSpacing: 0.42
handMaxWidth: 7.4 handMaxWidth: 7.4
handArc: 0.035 handArc: 0.035
handViewportY: 0.16
handViewDistance: 3.6
handViewMaxWidth: 2
handViewScale: 0.9
handViewRoll: 3.2
handViewArc: 0.02
guardianRowGap: 0.55 guardianRowGap: 0.55
guardianSpacing: 0.44 guardianSpacing: 0.44
guardianMaxWidth: 3.6 guardianMaxWidth: 3.6
damageX: 3.5 damageX: 3.5
pileX: 3.55 pileX: 3.55
cameraPosition: {x: 0, y: 8.7, z: -12.4} cameraPosition: {x: 0, y: 6.9, z: -9.8}
cameraTarget: {x: 0, y: 0, z: 0.3} cameraTarget: {x: 0, y: 0, z: -0.15}
cameraFov: 50 cameraFov: 50
orbitYawLimit: 32 orbitYawLimit: 32
orbitPitchMin: 20 orbitPitchMin: 20

View File

@ -41,7 +41,7 @@ namespace Vanguard.Board3D
public float smoothRotateSpeed = 14f; public float smoothRotateSpeed = 14f;
public float handHoverLift = 0.08f; public float handHoverLift = 0.08f;
[Header("Рука")] [Header("Рука на столе (fallback без камеры)")]
public float handCenterZ = -5.3f; public float handCenterZ = -5.3f;
public float handHeight = 1.12f; public float handHeight = 1.12f;
[Range(0f, 85f)] public float handTilt = 58f; [Range(0f, 85f)] public float handTilt = 58f;
@ -49,6 +49,16 @@ namespace Vanguard.Board3D
public float handMaxWidth = 7.4f; public float handMaxWidth = 7.4f;
public float handArc = 0.035f; 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("Круг защиты")] [Header("Круг защиты")]
public float guardianRowGap = 0.55f; public float guardianRowGap = 0.55f;
public float guardianSpacing = 0.44f; public float guardianSpacing = 0.44f;
@ -61,8 +71,8 @@ namespace Vanguard.Board3D
public float pileX = 3.55f; public float pileX = 3.55f;
[Header("Камера")] [Header("Камера")]
public Vector3 cameraPosition = new Vector3(0f, 8.7f, -12.4f); public Vector3 cameraPosition = new Vector3(0f, 6.9f, -9.8f);
public Vector3 cameraTarget = new Vector3(0f, 0f, 0.3f); public Vector3 cameraTarget = new Vector3(0f, 0f, -0.15f);
public float cameraFov = 50f; public float cameraFov = 50f;
public float orbitYawLimit = 32f; public float orbitYawLimit = 32f;
public float orbitPitchMin = 20f; public float orbitPitchMin = 20f;
@ -131,6 +141,12 @@ namespace Vanguard.Board3D
snapRadius = Mathf.Clamp(snapRadius, 0.2f, 1.5f); snapRadius = Mathf.Clamp(snapRadius, 0.2f, 1.5f);
handMaxWidth = Mathf.Max(1f, handMaxWidth); handMaxWidth = Mathf.Max(1f, handMaxWidth);
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth); 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); orbitPitchMax = Mathf.Clamp(orbitPitchMax, orbitPitchMin + 1f, 85f);
zoomMax = Mathf.Max(zoomMin + 0.5f, zoomMax); zoomMax = Mathf.Max(zoomMin + 0.5f, zoomMax);
} }

View File

@ -149,11 +149,12 @@ namespace Vanguard.Board3D
bool faceUp, bool faceUp,
bool rested, bool rested,
bool immediate, bool immediate,
bool damageLayout = false) bool damageLayout = false,
float scale = 1f)
{ {
SetState(baseRotation, faceUp, rested, damageLayout); SetState(baseRotation, faceUp, rested, damageLayout);
_targetPosition = position; _targetPosition = position;
_targetScale = Vector3.one; _targetScale = Vector3.one * Mathf.Max(0.05f, scale);
_immediate = immediate; _immediate = immediate;
if (_dragging) if (_dragging)

View File

@ -28,6 +28,7 @@ namespace Vanguard.Board3D
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2]; private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
private readonly HashSet<BoardSlotKey> _activeHighlights = new HashSet<BoardSlotKey>(); private readonly HashSet<BoardSlotKey> _activeHighlights = new HashSet<BoardSlotKey>();
private readonly HashSet<BoardSlotKey> _validDropSlots = new HashSet<BoardSlotKey>(); private readonly HashSet<BoardSlotKey> _validDropSlots = new HashSet<BoardSlotKey>();
private readonly List<HandCardBinding> _handBindings = new List<HandCardBinding>();
private bool _guardianDropAllowed; private bool _guardianDropAllowed;
private PlaymatLayoutConfig _config; private PlaymatLayoutConfig _config;
@ -150,6 +151,7 @@ namespace Vanguard.Board3D
{ {
CancelPointer(); CancelPointer();
_hasRendered = false; _hasRendered = false;
_handBindings.Clear();
} }
else else
{ {
@ -176,7 +178,10 @@ namespace Vanguard.Board3D
SetSelfPlayer(view.You); SetSelfPlayer(view.You);
_view = view; _view = view;
state ??= new BoardRenderState(); state ??= new BoardRenderState();
if (_camera == null)
_camera = Camera.main;
_handBindings.Clear();
var specs = new List<CardSpec>(40); var specs = new List<CardSpec>(40);
BuildSpecs(view, state, specs); BuildSpecs(view, state, specs);
@ -203,8 +208,17 @@ namespace Vanguard.Board3D
spec.FaceUp, spec.FaceUp,
spec.Rested, spec.Rested,
immediateAll || changed, immediateAll || changed,
spec.DamageLayout); spec.DamageLayout,
spec.Scale);
card.SetHighlight(spec.Highlight); card.SetHighlight(spec.Highlight);
if (spec.IsHand)
{
_handBindings.Add(new HandCardBinding(
card,
spec.HandIndex,
spec.HandCount));
}
} }
if (_cards.Count > 0) if (_cards.Count > 0)
@ -228,6 +242,7 @@ namespace Vanguard.Board3D
public void Clear() public void Clear()
{ {
ClearCards(); ClearCards();
_handBindings.Clear();
_view = null; _view = null;
_hasRendered = false; _hasRendered = false;
} }
@ -597,7 +612,7 @@ namespace Vanguard.Board3D
for (var i = 0; i < count; i++) for (var i = 0; i < count; i++)
{ {
var card = self.Hand[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; Color? highlight = null;
if (state.SelectedHandInstanceIds.Contains(card.InstanceId)) if (state.SelectedHandInstanceIds.Contains(card.InstanceId))
highlight = _config.selectedColor; highlight = _config.selectedColor;
@ -609,15 +624,99 @@ namespace Vanguard.Board3D
Key = "H:" + card.InstanceId, Key = "H:" + card.InstanceId,
Ref = BoardCardRef.Hand(self.Index, i, card), Ref = BoardCardRef.Hand(self.Index, i, card),
Art = ResolveArt(card.PrintingId), Art = ResolveArt(card.PrintingId),
Position = pose.Position, Position = position,
BaseRotation = Quaternion.Euler(_config.handTilt, pose.Yaw, 0f), BaseRotation = rotation,
FaceUp = true, FaceUp = true,
Rested = false, Rested = false,
Scale = scale,
IsHand = true,
HandIndex = i,
HandCount = count,
Highlight = highlight, Highlight = highlight,
}); });
} }
} }
/// <summary>
/// Рука висит в поле зрения камеры снизу по центру и всегда смотрит
/// на игрока. При отсутствии камеры используется старый настольный
/// вариант — для EditMode-проверок.
/// </summary>
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) private void UpdatePiles(MatchView view)
{ {
var self = view.Players.FirstOrDefault(player => player.Index == view.You); var self = view.Players.FirstOrDefault(player => player.Index == view.You);
@ -717,6 +816,8 @@ namespace Vanguard.Board3D
private void Update() private void Update()
{ {
UpdateHandView();
var delta = Time.unscaledDeltaTime; var delta = Time.unscaledDeltaTime;
foreach (var card in _cards.Values) foreach (var card in _cards.Values)
{ {
@ -909,6 +1010,9 @@ namespace Vanguard.Board3D
var targetValue = target.Value; var targetValue = target.Value;
var targetRef = targetValue.ToRef(); var targetRef = targetValue.ToRef();
if (source.IsHand)
_handBindings.RemoveAll(binding => binding.Card == card);
// Атака — это клик по цели, карта остаётся на своём круге. // Атака — это клик по цели, карта остаётся на своём круге.
if ((source.IsField && targetValue.Player != source.Player) if ((source.IsField && targetValue.Player != source.Player)
|| (source.IsHand && 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 private sealed class CardSpec
{ {
public string Key; public string Key;
@ -1130,6 +1248,10 @@ namespace Vanguard.Board3D
public bool FaceUp; public bool FaceUp;
public bool Rested; public bool Rested;
public bool DamageLayout; public bool DamageLayout;
public float Scale = 1f;
public bool IsHand;
public int HandIndex;
public int HandCount;
public Color? Highlight; public Color? Highlight;
} }
} }

View File

@ -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>();
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] [Test]
public void Pool_Reuses_Cards_Between_Snapshots() public void Pool_Reuses_Cards_Between_Snapshots()
{ {

View File

@ -854,15 +854,19 @@ Battle; Boot и общие сервисы остаются персистент
- `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики, - `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики,
drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и
индикатором точки броска. Ошибка интента возвращает карту на место. индикатором точки броска. Ошибка интента возвращает карту на место.
- Камера: `BoardCameraRig` — ПКМ орбита (±32°), СКМ панорама, колесо зум - Камера приближена: позиция `(0, 6.9, -9.8)`, цель `(0, 0, -0.15)`, FOV 50.
Рука не лежит на столе, а закреплена в экране снизу по центру
(`handViewportY`, `handViewDistance`) и всегда развёрнута лицом к игроку;
при орбите камеры веер следует за полем зрения, драг из руки уходит на стол.
- `BoardCameraRig` — ПКМ орбита (±32°), СКМ панорама, колесо зум
(9.5–16); ограничения в конфиге. (9.5–16); ограничения в конфиге.
- Душа: клик по стопке или кнопка «Душа: n» в верхней панели открывает - Душа: клик по стопке или кнопка «Душа: n» в верхней панели открывает
оверлей со списком карт. оверлей со списком карт.
- Песочница: `Assets/Vanguard/Scenes/GameBoard3D.unity` + - Песочница: `Assets/Vanguard/Scenes/GameBoard3D.unity` +
`PlaymatSandbox`; собирается `Vanguard → Rebuild Playmat Sandbox`, `PlaymatSandbox`; собирается `Vanguard → Rebuild Playmat Sandbox`,
проверяется `SceneSmoke.RunPlaymat`. проверяется `SceneSmoke.RunPlaymat`.
- Проверки: EditMode **17/17** (геометрия, зеркальность, правила, рендер - Проверки: EditMode **18/18** (геометрия, зеркальность, правила, рендер
3D-карт, пул), `SCENE_SMOKE success=True`, 3D-карт, пул, рука в поле зрения), `SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=20`. `PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`.
- Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены; - Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены;
модели карт/стола — процедурные примитивы. модели карт/стола — процедурные примитивы.

View File

@ -65,7 +65,9 @@
остаются uGUI. Карты — тонкие 3D-объекты с артом, рубашкой, торцом и остаются uGUI. Карты — тонкие 3D-объекты с артом, рубашкой, торцом и
коллайдером. Rest (атака/поддержка) — поворот по часовой стрелке вокруг Y, коллайдером. Rest (атака/поддержка) — поворот по часовой стрелке вокруг Y,
урон лежит на боку против часовой; закрытость — переворот вокруг X. урон лежит на боку против часовой; закрытость — переворот вокруг X.
Драг-н-дроп на плоскость стола с подсветкой целей и плавным снапом. Рука закреплена в поле зрения камеры снизу по центру и всегда смотрит
на игрока, а не лежит на столе. Драг-н-дроп на плоскость стола с
подсветкой целей и плавным снапом; камера приближена.
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у - Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
противника зеркально; между полями общий круг защиты; душа — стопка под противника зеркально; между полями общий круг защиты; душа — стопка под
V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу, V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу,
@ -131,10 +133,11 @@ curl http://127.0.0.1:18080/health
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
``` ```
Клиентские проверки: **17/17** EditMode (сборка всех экранов, импорт арта, Клиентские проверки: **18/18** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля и правила 3D-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения), `SCENE_SMOKE success=True`, `PLAYMAT_SMOKE success=True cards=20`. перемещения, рука в поле зрения камеры), `SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены: ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены: