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.backgroundColor = palette.Background;
camera.fieldOfView = theme == SceneTheme.Battle ? 50f : 52f;
camera.fieldOfView = theme == SceneTheme.Battle ? 46f : 52f;
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),
_ => new Vector3(0f, 3.2f, -10f),
};
camera.transform.rotation = theme == SceneTheme.Battle
? Quaternion.Euler(60.5f, 0f, 0f)
? Quaternion.Euler(35.9f, 0f, 0f)
: Quaternion.Euler(8f, 0f, 0f);
}

View File

@ -37,10 +37,10 @@ MonoBehaviour:
handMaxWidth: 7.4
handArc: 0.035
handViewportX: 0.44
handViewportY: 0.18
handViewDistance: 3.8
handViewMaxWidth: 1.8
handViewScale: 0.62
handViewportY: 0.1
handViewDistance: 4
handViewMaxWidth: 1.4
handViewScale: 0.5
handViewRoll: 3.2
handViewArc: 0.02
guardianRowGap: 0.55
@ -48,9 +48,9 @@ MonoBehaviour:
guardianMaxWidth: 3.6
damageX: 3
pileX: 2.95
cameraPosition: {x: 1.4, y: 9, z: -5.2}
cameraTarget: {x: 1.4, y: 0, z: -0.1}
cameraFov: 50
cameraPosition: {x: 1.4, y: 5.8, z: -8}
cameraTarget: {x: 1.4, y: 0, z: 0}
cameraFov: 46
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}

View File

@ -53,11 +53,11 @@ namespace Vanguard.Board3D
[Tooltip("Доля ширины экрана для центра карт: 0 — левый край, 0.5 — середина.")]
[Range(0.05f, 0.95f)] public float handViewportX = 0.44f;
[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("Расстояние от камеры до веера руки, метры.")]
public float handViewDistance = 3.8f;
public float handViewMaxWidth = 1.8f;
public float handViewScale = 0.62f;
public float handViewDistance = 4.0f;
public float handViewMaxWidth = 1.4f;
public float handViewScale = 0.5f;
public float handViewRoll = 3.2f;
public float handViewArc = 0.02f;
@ -74,9 +74,9 @@ namespace Vanguard.Board3D
[Header("Камера")]
[Tooltip("Фиксированная камера над полем. Не двигается игроком.")]
public Vector3 cameraPosition = new Vector3(1.4f, 9.0f, -5.2f);
public Vector3 cameraTarget = new Vector3(1.4f, 0f, -0.1f);
public float cameraFov = 50f;
public Vector3 cameraPosition = new Vector3(1.4f, 5.8f, -8.0f);
public Vector3 cameraTarget = new Vector3(1.4f, 0f, 0f);
public float cameraFov = 46f;
[Header("Палитра")]
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)
{
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);
}
public Vector3 DropPosition(int localSeat)
{
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);
}

View File

@ -45,20 +45,12 @@ namespace Vanguard.Board3D
new Vector3(halfW, 0f, halfH),
new Vector3(-halfW, 0f, halfH),
},
uv = front
? new[]
uv = new[]
{
new Vector2(0f, 0f),
new Vector2(1f, 0f),
new Vector2(1f, 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 },
triangles = new[]

View File

@ -79,6 +79,7 @@ namespace Vanguard.Board3D
public event Action<BoardCardRef> SlotClicked;
public event Action<BoardCardRef, BoardCardRef> CardDropped;
public event Action<BoardCardRef> PileClicked;
public event Action<BoardCardRef> PileRightClicked;
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),
divider);
var railMaterial = BoardMaterials.CreateLit(_config.lineColor, 0.9f);
var railMaterial = BoardMaterials.CreateUnlit(
null,
new Color(0.40f, 0.29f, 0.11f));
CreateBox(
"Rail Left",
new Vector3(-(_config.boardHalfWidth + 1.02f), 0.04f, 0f),
@ -809,9 +812,27 @@ namespace Vanguard.Board3D
card.Tick(delta);
}
HandleRightClick();
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()
{
if (!_hasRendered && _view == null)

View File

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

View File

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

View File

@ -80,6 +80,21 @@ namespace Vanguard.Tests
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]
public void Drop_Target_Finds_Own_And_Opponent_Circles()
{

View File

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

View File

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