Refine board camera, hand, piles and card backs
CI / test (push) Successful in 31m46s Details

This commit is contained in:
MpakobecEx 2026-10-22 20:07:57 +05:00
parent b4fa5d132f
commit e3243e58b6
11 changed files with 125 additions and 88 deletions

View File

@ -51,15 +51,15 @@ namespace Vanguard.App
camera.clearFlags = CameraClearFlags.SolidColor; camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = palette.Background; camera.backgroundColor = palette.Background;
camera.fieldOfView = theme == SceneTheme.Battle ? 50f : 52f; camera.fieldOfView = theme == SceneTheme.Battle ? 46f : 52f;
camera.transform.position = theme switch camera.transform.position = theme switch
{ {
SceneTheme.Battle => new Vector3(1.4f, 9f, -5.2f), SceneTheme.Battle => new Vector3(1.4f, 5.8f, -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(60.5f, 0f, 0f) ? Quaternion.Euler(35.9f, 0f, 0f)
: Quaternion.Euler(8f, 0f, 0f); : Quaternion.Euler(8f, 0f, 0f);
} }

View File

@ -37,10 +37,10 @@ MonoBehaviour:
handMaxWidth: 7.4 handMaxWidth: 7.4
handArc: 0.035 handArc: 0.035
handViewportX: 0.44 handViewportX: 0.44
handViewportY: 0.18 handViewportY: 0.1
handViewDistance: 3.8 handViewDistance: 4
handViewMaxWidth: 1.8 handViewMaxWidth: 1.4
handViewScale: 0.62 handViewScale: 0.5
handViewRoll: 3.2 handViewRoll: 3.2
handViewArc: 0.02 handViewArc: 0.02
guardianRowGap: 0.55 guardianRowGap: 0.55
@ -48,9 +48,9 @@ MonoBehaviour:
guardianMaxWidth: 3.6 guardianMaxWidth: 3.6
damageX: 3 damageX: 3
pileX: 2.95 pileX: 2.95
cameraPosition: {x: 1.4, y: 9, z: -5.2} cameraPosition: {x: 1.4, y: 5.8, z: -8}
cameraTarget: {x: 1.4, y: 0, z: -0.1} cameraTarget: {x: 1.4, y: 0, z: 0}
cameraFov: 50 cameraFov: 46
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1} tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1} matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}
matOpponentColor: {r: 0.13, g: 0.065, b: 0.075, a: 1} matOpponentColor: {r: 0.13, g: 0.065, b: 0.075, a: 1}

View File

@ -53,11 +53,11 @@ namespace Vanguard.Board3D
[Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")] [Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")]
[Range(0.05f, 0.95f)] public float handViewportX = 0.44f; [Range(0.05f, 0.95f)] public float handViewportX = 0.44f;
[Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")] [Tooltip("Доля высоты экрана для центра карт: 0 — низ, 0.5 — середина.")]
[Range(0.05f, 0.45f)] public float handViewportY = 0.18f; [Range(0.05f, 0.45f)] public float handViewportY = 0.10f;
[Tooltip("Расстояние от камеры до веера руки, метры.")] [Tooltip("Расстояние от камеры до веера руки, метры.")]
public float handViewDistance = 3.8f; public float handViewDistance = 4.0f;
public float handViewMaxWidth = 1.8f; public float handViewMaxWidth = 1.4f;
public float handViewScale = 0.62f; public float handViewScale = 0.5f;
public float handViewRoll = 3.2f; public float handViewRoll = 3.2f;
public float handViewArc = 0.02f; public float handViewArc = 0.02f;
@ -74,9 +74,9 @@ namespace Vanguard.Board3D
[Header("Камера")] [Header("Камера")]
[Tooltip("Фиксированная камера над полем. Не двигается игроком.")] [Tooltip("Фиксированная камера над полем. Не двигается игроком.")]
public Vector3 cameraPosition = new Vector3(1.4f, 9.0f, -5.2f); public Vector3 cameraPosition = new Vector3(1.4f, 5.8f, -8.0f);
public Vector3 cameraTarget = new Vector3(1.4f, 0f, -0.1f); public Vector3 cameraTarget = new Vector3(1.4f, 0f, 0f);
public float cameraFov = 50f; public float cameraFov = 46f;
[Header("Палитра")] [Header("Палитра")]
public Color tableColor = new Color(0.045f, 0.04f, 0.055f); public Color tableColor = new Color(0.045f, 0.04f, 0.055f);

View File

@ -78,14 +78,14 @@ namespace Vanguard.Board3D
public Vector3 DeckPosition(int localSeat) public Vector3 DeckPosition(int localSeat)
{ {
var x = (localSeat == 0 ? 1f : -1f) * _config.pileX; var x = (localSeat == 0 ? 1f : -1f) * _config.pileX;
var z = ZSign(localSeat) * (_config.backRowZ + 0.15f); var z = ZSign(localSeat) * (_config.frontRowZ + 0.05f);
return new Vector3(x, _config.pileLift, z); return new Vector3(x, _config.pileLift, z);
} }
public Vector3 DropPosition(int localSeat) public Vector3 DropPosition(int localSeat)
{ {
var x = (localSeat == 0 ? 1f : -1f) * _config.pileX; var x = (localSeat == 0 ? 1f : -1f) * _config.pileX;
var z = ZSign(localSeat) * (_config.frontRowZ + 0.05f); var z = ZSign(localSeat) * (_config.backRowZ + 0.15f);
return new Vector3(x, _config.pileLift, z); return new Vector3(x, _config.pileLift, z);
} }

View File

@ -45,21 +45,13 @@ namespace Vanguard.Board3D
new Vector3(halfW, 0f, halfH), new Vector3(halfW, 0f, halfH),
new Vector3(-halfW, 0f, halfH), new Vector3(-halfW, 0f, halfH),
}, },
uv = front uv = new[]
? new[] {
{ new Vector2(0f, 0f),
new Vector2(0f, 0f), new Vector2(1f, 0f),
new Vector2(1f, 0f), new Vector2(1f, 1f),
new Vector2(1f, 1f), new Vector2(0f, 1f),
new Vector2(0f, 1f), },
}
: new[]
{
new Vector2(0f, 1f),
new Vector2(1f, 1f),
new Vector2(1f, 0f),
new Vector2(0f, 0f),
},
normals = new[] { normal, normal, normal, normal }, normals = new[] { normal, normal, normal, normal },
triangles = new[] triangles = new[]
{ {

View File

@ -79,6 +79,7 @@ namespace Vanguard.Board3D
public event Action<BoardCardRef> SlotClicked; public event Action<BoardCardRef> SlotClicked;
public event Action<BoardCardRef, BoardCardRef> CardDropped; public event Action<BoardCardRef, BoardCardRef> CardDropped;
public event Action<BoardCardRef> PileClicked; public event Action<BoardCardRef> PileClicked;
public event Action<BoardCardRef> PileRightClicked;
public static GameBoard3D Create(Transform parent, PlaymatLayoutConfig config = null) public static GameBoard3D Create(Transform parent, PlaymatLayoutConfig config = null)
{ {
@ -276,7 +277,9 @@ namespace Vanguard.Board3D
new Vector3(width - 0.3f, 0.012f, 0.055f), new Vector3(width - 0.3f, 0.012f, 0.055f),
divider); divider);
var railMaterial = BoardMaterials.CreateLit(_config.lineColor, 0.9f); var railMaterial = BoardMaterials.CreateUnlit(
null,
new Color(0.40f, 0.29f, 0.11f));
CreateBox( CreateBox(
"Rail Left", "Rail Left",
new Vector3(-(_config.boardHalfWidth + 1.02f), 0.04f, 0f), new Vector3(-(_config.boardHalfWidth + 1.02f), 0.04f, 0f),
@ -809,9 +812,27 @@ namespace Vanguard.Board3D
card.Tick(delta); card.Tick(delta);
} }
HandleRightClick();
HandlePointer(); HandlePointer();
} }
private void HandleRightClick()
{
var mouse = Mouse.current;
if (mouse == null
|| !mouse.rightButton.wasPressedThisFrame
|| IsPointerOverUi())
{
return;
}
var ray = GetRay(mouse.position.ReadValue());
if (!TryRaycast(ray, out _, out _, out var pile) || pile == null)
return;
PileRightClicked?.Invoke(pile.Ref);
}
private void HandlePointer() private void HandlePointer()
{ {
if (!_hasRendered && _view == null) if (!_hasRendered && _view == null)

View File

@ -102,7 +102,7 @@ namespace Vanguard.Board3D
_top.transform.localPosition = new Vector3( _top.transform.localPosition = new Vector3(
0f, 0f,
(layers - 1) * thickness + 0.0015f, ((layers - 0.5f) * thickness) + 0.0015f,
0f); 0f);
if (topTexture != null) if (topTexture != null)

View File

@ -40,8 +40,6 @@ namespace Vanguard.UI
private readonly Button _concede; private readonly Button _concede;
private readonly Text _concedeLabel; private readonly Text _concedeLabel;
private readonly Button _reconnect; private readonly Button _reconnect;
private Button _soulCounter;
private Text _soulCounterLabel;
private readonly RectTransform _result; private readonly RectTransform _result;
private RectTransform _logContent; private RectTransform _logContent;
private Text _resultText; private Text _resultText;
@ -113,12 +111,6 @@ namespace Vanguard.UI
UiKit.MakeLiteral(_topInfo); UiKit.MakeLiteral(_topInfo);
UiKit.SetPreferredHeight(_topInfo, 38f); UiKit.SetPreferredHeight(_topInfo, 38f);
_soulCounter = UiKit.CreateButton(
top, "SoulCounter", "Душа: 0", OnSoulCounterClicked, 16);
_soulCounterLabel = _soulCounter.GetComponentInChildren<Text>();
var soulElement = UiKit.SetPreferredHeight(_soulCounter, 44f, 0f);
soulElement.preferredWidth = 132f;
_clock = UiKit.CreateText( _clock = UiKit.CreateText(
top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight); top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight);
UiKit.SetPreferredHeight(_clock, 44f, 0f); UiKit.SetPreferredHeight(_clock, 44f, 0f);
@ -198,6 +190,7 @@ namespace Vanguard.UI
_board3D.SlotClicked += OnBoard3DSlotClicked; _board3D.SlotClicked += OnBoard3DSlotClicked;
_board3D.CardDropped += OnBoard3DCardDropped; _board3D.CardDropped += OnBoard3DCardDropped;
_board3D.PileClicked += OnBoard3DPileClicked; _board3D.PileClicked += OnBoard3DPileClicked;
_board3D.PileRightClicked += OnBoard3DPileRightClicked;
_board3D.SetVisible(false); _board3D.SetVisible(false);
_root.gameObject.SetActive(false); _root.gameObject.SetActive(false);
@ -235,8 +228,6 @@ namespace Vanguard.UI
if (_soulOverlay != null) if (_soulOverlay != null)
_soulOverlay.gameObject.SetActive(false); _soulOverlay.gameObject.SetActive(false);
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
_soulCounterLabel.text = "Душа: 0";
_soulCounter.interactable = false;
if (_selfStatsText != null) if (_selfStatsText != null)
_selfStatsText.text = string.Empty; _selfStatsText.text = string.Empty;
if (_opponentStatsText != null) if (_opponentStatsText != null)
@ -779,7 +770,6 @@ namespace Vanguard.UI
_board3D?.SetSelfPlayer(_view.You); _board3D?.SetSelfPlayer(_view.You);
_board3D?.Render(_view, BuildBoardRenderState()); _board3D?.Render(_view, BuildBoardRenderState());
UpdateSoulCounter();
UpdateStatsOverlay(); UpdateStatsOverlay();
RebuildIntents(_view); RebuildIntents(_view);
@ -917,7 +907,7 @@ namespace Vanguard.UI
break; break;
case BoardZone.Soul: case BoardZone.Soul:
ShowSoul(card.Player); SetStatus("Душа открывается правой кнопкой мыши.", Muted);
break; break;
case BoardZone.Guardian: case BoardZone.Guardian:
@ -998,29 +988,29 @@ namespace Vanguard.UI
OnIntentClicked(intent); OnIntentClicked(intent);
} }
private void OnSoulCounterClicked() private void OnBoard3DPileRightClicked(BoardCardRef pile)
{ {
if (_view != null) if (_view == null)
ShowSoul(_view.You);
}
private void UpdateSoulCounter()
{
if (_soulCounterLabel == null)
return; return;
var self = _view?.Players.FirstOrDefault(player => player.Index == _view.You); if (pile.Zone == BoardZone.Soul)
_soulCounterLabel.text = "Душа: " + (self?.Soul?.Count ?? 0); ShowSoul(pile.Player);
_soulCounter.interactable = _view != null && !_view.IsFinished;
} }
private void OnBoard3DPileClicked(BoardCardRef pile) private void OnBoard3DPileClicked(BoardCardRef pile)
{ if (_view == null) {
if (_view == null)
return; return;
if (pile.Zone == BoardZone.Soul) if (pile.Zone == BoardZone.Soul)
{ {
ShowSoul(pile.Player); SetStatus("Душа открывается правой кнопкой мыши.", Muted);
return;
}
if (pile.Zone == BoardZone.Drop)
{
ShowDrop(pile.Player);
return; return;
} }
@ -1029,13 +1019,7 @@ namespace Vanguard.UI
return; return;
if (pile.Zone == BoardZone.Deck) if (pile.Zone == BoardZone.Deck)
{
SetStatus("В колоде " + player.DeckCount + " карт.", Muted); SetStatus("В колоде " + player.DeckCount + " карт.", Muted);
}
else if (pile.Zone == BoardZone.Drop)
{
SetStatus("В дропе " + (player.Drop?.Count ?? 0) + " карт.", Muted);
}
} }
private void BuildStatsOverlays() private void BuildStatsOverlays()
@ -1157,26 +1141,43 @@ namespace Vanguard.UI
private void ShowSoul(int playerIndex) private void ShowSoul(int playerIndex)
{ {
var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex); var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex);
if (player == null || _soulOverlay == null) if (player == null)
return; return;
_soulTitle.text = "Душа · " + player.Name + " · " + (player.Soul?.Count ?? 0); ShowCardList("Душа", player.Name, player.Soul);
}
private void ShowDrop(int playerIndex)
{
var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex);
if (player == null)
return;
ShowCardList("Дроп", player.Name, player.Drop);
}
private void ShowCardList(string title, string playerName, List<CardView> cards)
{
if (_soulOverlay == null)
return;
_soulTitle.text = title + " · " + playerName + " · " + (cards?.Count ?? 0);
ClearChildren(_soulContent); ClearChildren(_soulContent);
if (player.Soul == null || player.Soul.Count == 0) if (cards == null || cards.Count == 0)
{ {
var empty = UiKit.CreateText( var empty = UiKit.CreateText(
_soulContent, _soulContent,
"SoulEmpty", "CardListEmpty",
"В душе нет карт.", "Нет карт.",
17, 17,
Muted); Muted);
UiKit.SetPreferredHeight(empty, 80f); UiKit.SetPreferredHeight(empty, 80f);
} }
else else
{ {
for (var i = player.Soul.Count - 1; i >= 0; i--) for (var i = cards.Count - 1; i >= 0; i--)
AddSoulRow(player.Soul[i]); AddSoulRow(cards[i]);
} }
_soulOverlay.SetAsLastSibling(); _soulOverlay.SetAsLastSibling();

View File

@ -80,6 +80,21 @@ namespace Vanguard.Tests
Assert.IsTrue(hand.All(position => position.z < -4f)); Assert.IsTrue(hand.All(position => position.z < -4f));
} }
[Test]
public void Deck_Is_Closer_Than_Drop_After_Swap()
{
var config = PlaymatLayoutConfig.CreateDefault();
var layout = new PlaymatLayout(config);
var deck = layout.DeckPosition(0);
var drop = layout.DropPosition(0);
Assert.Greater(deck.z, drop.z);
var opponentDeck = layout.DeckPosition(1);
var opponentDrop = layout.DropPosition(1);
Assert.Less(opponentDeck.z, opponentDrop.z);
}
[Test] [Test]
public void Drop_Target_Finds_Own_And_Opponent_Circles() public void Drop_Target_Finds_Own_And_Opponent_Circles()
{ {

View File

@ -858,21 +858,26 @@ Battle; Boot и общие сервисы остаются персистент
- `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики, - `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики,
drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и
индикатором точки броска. Ошибка интента возвращает карту на место. индикатором точки броска. Ошибка интента возвращает карту на место.
- Камера фиксирована над полем: позиция `(1.4, 9, -5.2)`, цель - Камера фиксирована над полем: позиция `(1.4, 5.8, -8)`, цель
`(1.4, 0, -0.1)`, FOV 50; игрок её не двигает. Рука не лежит на столе, `(1.4, 0, 0)`, FOV 46; игрок её не двигает. Рука не лежит на столе,
а закреплена в экране снизу по центру (`handViewportX/Y`, а закреплена в экране снизу по центру (`handViewportX/Y`,
`handViewDistance`) и всегда развёрнута лицом к игроку; драг из руки `handViewDistance`) и всегда развёрнута лицом к игроку; уменьшена, чтобы
уходит на стол. не перекрывать задний ряд RC. Драг из руки уходит на стол.
- Статистика карт вынесена в отдельные UI-панели: своя — снизу слева, - Статистика карт вынесена в отдельные UI-панели: своя — снизу слева,
противника — сверху справа (`HAND/DECK/DROP/DMG/SOUL`); 3D-подписи противника — сверху справа (`Рука/Колода/Дроп/Урон/Душа`); 3D-подписи
стопок и зон больше не дублируют числа. стопок и зон больше не дублируют числа.
- Колода и дроп поменяны местами: колода ближе к игроку. Верх дропа
показывает последнюю положенную карту, ЛКМ открывает список всего дропа.
Душа открывается только ПКМ по стопке под V; кнопка души из топ-бара
убрана.
- Душа: клик по стопке или кнопка «Душа: 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 **18/18** (геометрия, зеркальность, правила, рендер - Проверки: EditMode **19/19** (геометрия, зеркальность, правила, рендер
3D-карт, пул, рука в поле зрения), `SCENE_SMOKE success=True`, 3D-карт, пул, рука в поле зрения, обмен колоды/дропа),
`SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`. `PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`.
- Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены; - Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены;
модели карт/стола — процедурные примитивы. модели карт/стола — процедурные примитивы.

View File

@ -67,17 +67,19 @@
урон лежит на боку против часовой; закрытость — переворот вокруг X. урон лежит на боку против часовой; закрытость — переворот вокруг X.
Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`), Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`),
общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру
и всегда смотрит на игрока, а не лежит на столе. Камера фиксирована над и всегда смотрит на игрока, а не лежит на столе; уменьшена, чтобы не
полем под небольшим углом и не двигается игроком. Драг-н-дроп на закрывать задний ряд RC. Камера фиксирована над полем под небольшим
плоскость стола с подсветкой целей и плавным снапом. углом и не двигается игроком. Драг-н-дроп на плоскость стола с
подсветкой целей и плавным снапом.
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у - Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
противника зеркально; между полями общий круг защиты; душа — стопка под противника зеркально; между полями общий круг защиты; душа — стопка под
V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу, V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу,
даже если он за вторым местом. Карты в кругах V/R принудительно даже если он за вторым местом. Карты в кругах V/R принудительно
показываются лицом вверх. показываются лицом вверх.
- Статистика карт — отдельное UI-наложение: своя панель снизу слева, - Статистика карт — отдельное UI-наложение: своя панель снизу слева,
противника — сверху справа. Кнопка «Душа: n» в верхней панели и клик по противника — сверху справа. Душа открывается только ПКМ по стопке под V;
стопке души открывают список карт души. дроп показывает верхнюю карту, по ЛКМ — список всех карт дропа; колода и
дроп поменяны местами (колода ближе к игроку).
- Зона урона: карты лежат открытыми и на боку; после Counter Blast - Зона урона: карты лежат открытыми и на боку; после Counter Blast
выбранная карта переворачивается рубашкой. Heal выбирает карту урона; выбранная карта переворачивается рубашкой. Heal выбирает карту урона;
Counter Blast — открытую карту до активации способности. Counter Blast — открытую карту до активации способности.
@ -137,10 +139,11 @@ curl http://127.0.0.1:18080/health
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
``` ```
Клиентские проверки: **18/18** EditMode (сборка всех экранов, импорт арта, Клиентские проверки: **19/19** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля, правила 3D-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения, рука в поле зрения камеры), `SCENE_SMOKE success=True`, перемещения, рука в поле зрения камеры, обмен колоды/дропа),
`SCENE_SMOKE success=True`,
`PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`. `PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены