Fix card faces, lock camera, add stats overlay and standard sleeve back
CI / test (push) Successful in 2m43s Details

This commit is contained in:
MpakobecEx 2026-10-22 18:59:24 +05:00
parent f2a01bae16
commit b4fa5d132f
19 changed files with 410 additions and 240 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -0,0 +1,132 @@
fileFormatVersion: 2
guid: fcf11b652a4a6f744917c23591d4b479
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationMethod: 0
spriteTessellationDetail: -1
spriteGeometrySubdivision: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: WebGL
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -54,12 +54,12 @@ namespace Vanguard.App
camera.fieldOfView = theme == SceneTheme.Battle ? 50f : 52f;
camera.transform.position = theme switch
{
SceneTheme.Battle => new Vector3(0f, 6.9f, -9.8f),
SceneTheme.Battle => new Vector3(1.4f, 9f, -5.2f),
SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f),
_ => new Vector3(0f, 3.2f, -10f),
};
camera.transform.rotation = theme == SceneTheme.Battle
? Quaternion.Euler(35.2f, 0f, 0f)
? Quaternion.Euler(60.5f, 0f, 0f)
: Quaternion.Euler(8f, 0f, 0f);
}

View File

@ -36,27 +36,21 @@ MonoBehaviour:
handSpacing: 0.42
handMaxWidth: 7.4
handArc: 0.035
handViewportY: 0.16
handViewDistance: 3.6
handViewMaxWidth: 2
handViewScale: 0.9
handViewportX: 0.44
handViewportY: 0.18
handViewDistance: 3.8
handViewMaxWidth: 1.8
handViewScale: 0.62
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: 6.9, z: -9.8}
cameraTarget: {x: 0, y: 0, z: -0.15}
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
orbitYawLimit: 32
orbitPitchMin: 20
orbitPitchMax: 62
zoomMin: 9.5
zoomMax: 16
zoomSpeed: 0.006
panSpeed: 0.0035
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

@ -50,12 +50,14 @@ namespace Vanguard.Board3D
public float handArc = 0.035f;
[Header("Рука в поле зрения")]
[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.16f;
[Range(0.05f, 0.45f)] public float handViewportY = 0.18f;
[Tooltip("Расстояние от камеры до веера руки, метры.")]
public float handViewDistance = 3.6f;
public float handViewMaxWidth = 2.0f;
public float handViewScale = 0.9f;
public float handViewDistance = 3.8f;
public float handViewMaxWidth = 1.8f;
public float handViewScale = 0.62f;
public float handViewRoll = 3.2f;
public float handViewArc = 0.02f;
@ -66,21 +68,15 @@ namespace Vanguard.Board3D
[Header("Стопки")]
[Tooltip("Расстояние от центра стола до зоны урона.")]
public float damageX = 3.5f;
public float damageX = 3.0f;
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
public float pileX = 3.55f;
public float pileX = 2.95f;
[Header("Камера")]
public Vector3 cameraPosition = new Vector3(0f, 6.9f, -9.8f);
public Vector3 cameraTarget = new Vector3(0f, 0f, -0.15f);
[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 float orbitYawLimit = 32f;
public float orbitPitchMin = 20f;
public float orbitPitchMax = 62f;
public float zoomMin = 9.5f;
public float zoomMax = 16f;
public float zoomSpeed = 0.006f;
public float panSpeed = 0.0035f;
[Header("Палитра")]
public Color tableColor = new Color(0.045f, 0.04f, 0.055f);
@ -142,13 +138,12 @@ namespace Vanguard.Board3D
handMaxWidth = Mathf.Max(1f, handMaxWidth);
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
handViewportY = Mathf.Clamp(handViewportY, 0.05f, 0.45f);
handViewportX = Mathf.Clamp(handViewportX, 0.05f, 0.95f);
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);
}
}
}

View File

@ -57,6 +57,10 @@ namespace Vanguard.Board3D
private static BoardArt SandboxArt(string printingId)
{
var texture = LoadSandboxTexture(printingId);
if (texture != null)
return new BoardArt(texture, Color.white, printingId);
var clan = printingId != null && printingId.Contains("002")
? "Kagero"
: "RoyalPaladin";
@ -67,6 +71,36 @@ namespace Vanguard.Board3D
printingId);
}
private static Texture2D LoadSandboxTexture(string printingId)
{
if (string.IsNullOrEmpty(printingId))
return null;
var slash = printingId.IndexOf('/');
if (slash <= 0 || slash >= printingId.Length - 1)
return null;
var set = printingId.Substring(0, slash).ToLowerInvariant();
var tail = printingId.Substring(slash + 1);
var number = string.Empty;
for (var i = 0; i < tail.Length; i++)
{
var c = tail[i];
if (char.IsDigit(c) || (i == 0 && (c == 'S' || c == 's')))
{
number += char.ToLowerInvariant(c);
continue;
}
break;
}
if (number.Length == 0)
return null;
return Resources.Load<Texture2D>("CardArt/" + set + "_" + number);
}
private static BoardRenderState BuildState()
{
var state = new BoardRenderState();

View File

@ -1,132 +0,0 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
namespace Vanguard.Board3D
{
/// <summary>
/// Ограниченная орбита камеры: ПКМ — поворот, СКМ — панорама,
/// колесо — зум. Левая кнопка остаётся картам и UI.
/// </summary>
public sealed class BoardCameraRig : MonoBehaviour
{
private PlaymatLayoutConfig _config;
private Vector3 _target;
private float _distance = 15f;
private float _yaw;
private float _pitch = 35f;
private bool _orbiting;
private bool _panning;
private Vector2 _lastPointer;
public void Configure(PlaymatLayoutConfig config)
{
_config = config != null ? config : PlaymatLayoutConfig.Default;
_target = _config.cameraTarget;
var offset = _config.cameraPosition - _target;
_distance = Mathf.Clamp(
offset.magnitude,
_config.zoomMin,
_config.zoomMax);
_pitch = Mathf.Clamp(
Mathf.Asin(Mathf.Clamp(offset.y / Mathf.Max(0.001f, _distance), -1f, 1f))
* Mathf.Rad2Deg,
_config.orbitPitchMin,
_config.orbitPitchMax);
_yaw = Mathf.Atan2(-offset.x, -offset.z) * Mathf.Rad2Deg;
_yaw = Mathf.Clamp(_yaw, -_config.orbitYawLimit, _config.orbitYawLimit);
Apply();
}
private void Update()
{
if (_config == null)
return;
var mouse = Mouse.current;
if (mouse == null)
return;
var pointer = mouse.position.ReadValue();
var overUi = IsPointerOverUi();
if (mouse.rightButton.wasPressedThisFrame && !overUi)
{
_orbiting = true;
_lastPointer = pointer;
}
if (mouse.rightButton.wasReleasedThisFrame)
_orbiting = false;
if (_orbiting && mouse.rightButton.isPressed)
{
var delta = pointer - _lastPointer;
_lastPointer = pointer;
_yaw = Mathf.Clamp(
_yaw + (delta.x * 0.18f),
-_config.orbitYawLimit,
_config.orbitYawLimit);
_pitch = Mathf.Clamp(
_pitch - (delta.y * 0.16f),
_config.orbitPitchMin,
_config.orbitPitchMax);
Apply();
}
if (mouse.middleButton.wasPressedThisFrame && !overUi)
{
_panning = true;
_lastPointer = pointer;
}
if (mouse.middleButton.wasReleasedThisFrame)
_panning = false;
if (_panning && mouse.middleButton.isPressed)
{
var delta = pointer - _lastPointer;
_lastPointer = pointer;
var rotation = Quaternion.Euler(_pitch, _yaw, 0f);
var right = rotation * Vector3.right;
var forward = rotation * Vector3.forward;
right.y = 0f;
forward.y = 0f;
right.Normalize();
forward.Normalize();
var scale = _config.panSpeed * _distance;
_target -= (right * (delta.x * scale)) + (forward * (delta.y * scale));
_target.x = Mathf.Clamp(_target.x, -2.5f, 2.5f);
_target.z = Mathf.Clamp(_target.z, -2f, 2f);
Apply();
}
var scroll = mouse.scroll.ReadValue().y;
if (!overUi && Mathf.Abs(scroll) > 0.01f)
{
_distance = Mathf.Clamp(
_distance - (scroll * _config.zoomSpeed),
_config.zoomMin,
_config.zoomMax);
Apply();
}
}
private void Apply()
{
var rotation = Quaternion.Euler(_pitch, _yaw, 0f);
transform.rotation = rotation;
transform.position = _target - (rotation * Vector3.forward * _distance);
var camera = GetComponent<Camera>();
if (camera != null)
camera.fieldOfView = _config.cameraFov;
}
private static bool IsPointerOverUi()
{
var eventSystem = EventSystem.current;
return eventSystem != null && eventSystem.IsPointerOverGameObject();
}
}
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 30bd2e29295c0b241bb1f29e9dc73ca8

View File

@ -70,7 +70,7 @@ namespace Vanguard.Board3D
var text = BoardText.Create(
go.transform,
label,
0.14f,
0.055f,
new Color(1f, 1f, 1f, 0.34f),
new Vector3(0f, 0.001f, 0f));
text.name = "Slot Label";

View File

@ -36,6 +36,10 @@ namespace Vanguard.Board3D
{
get
{
if (_back != null)
return _back;
_back = Resources.Load<Texture2D>("CardArt/vanguard_back");
if (_back == null)
_back = BuildBack();
return _back;

View File

@ -43,8 +43,6 @@ namespace Vanguard.Board3D
private Transform _pilesRoot;
private Transform _zonesRoot;
private Camera _camera;
private BoardCameraRig _cameraRig;
private bool _ownsRig;
private BoardZoneView _guardianZone;
private BoardZoneView _dropIndicator;
@ -159,8 +157,6 @@ namespace Vanguard.Board3D
}
gameObject.SetActive(visible);
if (_cameraRig != null)
_cameraRig.enabled = visible;
}
public void Render(MatchView view, BoardRenderState state)
@ -271,7 +267,9 @@ namespace Vanguard.Board3D
new Vector3(matWidth, 0.008f, matDepth),
_config.matOpponentColor);
var divider = BoardMaterials.CreateLit(_config.lineColor, 1.2f);
var divider = BoardMaterials.CreateUnlit(
null,
new Color(0.34f, 0.24f, 0.10f));
CreateBox(
"Center Line",
new Vector3(0f, 0.011f, 0f),
@ -339,8 +337,8 @@ namespace Vanguard.Board3D
guardianDepth,
Mathf.Max(0.022f, _config.cardWidth * 0.04f),
_config.guardianColor,
"GUARDIAN",
0.11f,
null,
0f,
-(guardianDepth * 0.5f) - 0.2f,
new Color(0.72f, 0.92f, 1f, 0.75f),
_config.highlightColor);
@ -360,8 +358,8 @@ namespace Vanguard.Board3D
2.25f,
Mathf.Max(0.018f, _config.cardWidth * 0.03f),
_config.damageColor,
"DAMAGE",
0.1f,
null,
0f,
-zSign * 1.32f,
new Color(1f, 0.78f, 0.76f, 0.75f),
_config.highlightColor);
@ -433,17 +431,11 @@ namespace Vanguard.Board3D
if (_camera == null)
return;
if (_cameraRig == null)
{
_cameraRig = _camera.GetComponent<BoardCameraRig>();
if (_cameraRig == null)
{
_cameraRig = _camera.gameObject.AddComponent<BoardCameraRig>();
_ownsRig = true;
}
}
_cameraRig.Configure(_config);
_camera.transform.position = _config.cameraPosition;
var direction = _config.cameraTarget - _config.cameraPosition;
if (direction.sqrMagnitude > 0.0001f)
_camera.transform.rotation = Quaternion.LookRotation(direction, Vector3.up);
_camera.fieldOfView = _config.cameraFov;
}
private void BuildSpecs(
@ -496,7 +488,8 @@ namespace Vanguard.Board3D
Art = ResolveArt(card.PrintingId),
Position = _layout.CirclePosition(localSeat, slot),
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
FaceUp = card.FaceUp,
// Карты в кругах V/R всегда открыты, независимо от снимка.
FaceUp = true,
Rested = card.Rested,
Highlight = state.Highlights.Contains(key)
? _config.highlightColor
@ -669,7 +662,9 @@ namespace Vanguard.Board3D
var offset = index - (safeCount - 1) * 0.5f;
var halfHeight = Mathf.Tan(
_camera.fieldOfView * 0.5f * Mathf.Deg2Rad) * _config.handViewDistance;
var x = offset * step;
var halfWidth = halfHeight * Mathf.Max(0.1f, _camera.aspect);
var x = ((_config.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
@ -725,22 +720,11 @@ namespace Vanguard.Board3D
UpdatePileSide(self, _deckSelf, _dropSelf, _soulSelf);
UpdatePileSide(opponent, _deckOpponent, _dropOpponent, _soulOpponent);
var damageSelf = self != null
? self.FaceDownDamage + (self.FaceUpDamage?.Count ?? 0)
: 0;
var damageOpponent = opponent != null
? opponent.FaceDownDamage + (opponent.FaceUpDamage?.Count ?? 0)
: 0;
_damageZones[0]?.SetLabel("DAMAGE " + damageSelf);
_damageZones[1]?.SetLabel("DAMAGE " + damageOpponent);
_damageZones[0]?.SetLabel(string.Empty);
_damageZones[1]?.SetLabel(string.Empty);
if (_guardianZone != null)
{
var shield = view.Battle != null ? view.Battle.GuardShield : 0;
var checks = view.Battle != null ? view.Battle.ChecksRemaining : 0;
_guardianZone.SetLabel(
"GUARDIAN SHIELD " + shield + " CHECK " + checks);
}
_guardianZone.SetLabel(string.Empty);
}
private void UpdatePileSide(
@ -1214,14 +1198,6 @@ namespace Vanguard.Board3D
{
if (_instance == this)
_instance = null;
if (_cameraRig != null && _ownsRig)
{
if (Application.isPlaying)
Destroy(_cameraRig);
else
DestroyImmediate(_cameraRig);
}
}
private sealed class HandCardBinding

View File

@ -73,12 +73,15 @@ namespace Vanguard.Board3D
_top.sharedMaterial = BoardMaterials.White;
BoardMaterials.SetShadows(_top, false);
_label = BoardText.Create(
transform,
title,
0.115f,
new Color(0.86f, 0.90f, 1f),
new Vector3(0f, 0.02f, -(height * 0.78f)));
if (Zone != BoardZone.Soul)
{
_label = BoardText.Create(
transform,
title,
0.05f,
new Color(0.86f, 0.90f, 1f),
new Vector3(0f, 0.02f, -(height * 0.78f)));
}
var collider = gameObject.AddComponent<BoxCollider>();
collider.size = new Vector3(width * 1.06f, 0.16f, height * 1.06f);
@ -128,7 +131,7 @@ namespace Vanguard.Board3D
_top.SetPropertyBlock(_block);
}
BoardText.SetText(_label, title + " " + safe);
BoardText.SetText(_label, title);
}
private static void RemoveCollider(GameObject target)

View File

@ -80,6 +80,8 @@ namespace Vanguard.UI
private RectTransform _soulOverlay;
private RectTransform _soulContent;
private Text _soulTitle;
private Text _selfStatsText;
private Text _opponentStatsText;
public BattleView(Transform parent, MetaService meta, Action onExit)
{
@ -129,7 +131,7 @@ namespace Vanguard.UI
right.anchorMin = new Vector2(1f, 0f);
right.anchorMax = new Vector2(1f, 1f);
right.pivot = new Vector2(1f, 0.5f);
right.offsetMin = new Vector2(-528f, 28f);
right.offsetMin = new Vector2(-456f, 28f);
right.offsetMax = new Vector2(-36f, -108f);
UiKit.AddVerticalLayout(right, 14, 10);
@ -188,6 +190,7 @@ namespace Vanguard.UI
_feedback.gameObject.SetActive(false);
BuildSoulOverlay();
BuildStatsOverlays();
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
_board3D.ArtProvider = ResolveBoardArt;
@ -234,6 +237,10 @@ namespace Vanguard.UI
_topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId;
_soulCounterLabel.text = "Душа: 0";
_soulCounter.interactable = false;
if (_selfStatsText != null)
_selfStatsText.text = string.Empty;
if (_opponentStatsText != null)
_opponentStatsText.text = string.Empty;
SetStatus("Подключаемся к матчу…", Muted);
_root.gameObject.SetActive(true);
_ = LoadCatalogAsync();
@ -773,6 +780,7 @@ namespace Vanguard.UI
_board3D?.SetSelfPlayer(_view.You);
_board3D?.Render(_view, BuildBoardRenderState());
UpdateSoulCounter();
UpdateStatsOverlay();
RebuildIntents(_view);
UpdateClock();
@ -848,9 +856,16 @@ namespace Vanguard.UI
_catalog.TryGetValue(printingId, out var meta);
var clan = meta != null ? meta.Clan : "None";
var nameId = meta != null ? meta.NameId : printingId;
var sprite = CardImageService.Resolve(
meta != null ? meta.ImageKey : null,
CardArt.ForCard(nameId ?? printingId ?? "card", clan));
var sprite = CardImageService.Resolve(meta != null ? meta.ImageKey : null, null);
if (sprite == null)
{
sprite = CardImageService.Resolve(
CardImageService.ImageKeyForPrinting(printingId),
null);
}
if (sprite == null)
sprite = CardArt.ForCard(nameId ?? printingId ?? "card", clan);
return new BoardArt(
sprite != null ? sprite.texture : null,
CardArt.ClanColor(clan),
@ -1023,6 +1038,86 @@ namespace Vanguard.UI
}
}
private void BuildStatsOverlays()
{
_selfStatsText = CreateStatsPanel(
"SelfStats",
new Vector2(0f, 0f),
new Vector2(0f, 0f),
new Vector2(30f, 24f),
TextAnchor.MiddleLeft);
_opponentStatsText = CreateStatsPanel(
"OpponentStats",
new Vector2(1f, 1f),
new Vector2(1f, 1f),
new Vector2(-488f, -108f),
TextAnchor.MiddleRight);
}
private Text CreateStatsPanel(
string name,
Vector2 anchor,
Vector2 pivot,
Vector2 position,
TextAnchor alignment)
{
var panel = UiKit.CreatePanel(
_root,
name,
new Color(0.02f, 0.035f, 0.055f, 0.72f));
panel.anchorMin = anchor;
panel.anchorMax = anchor;
panel.pivot = pivot;
panel.anchoredPosition = position;
panel.sizeDelta = new Vector2(360f, 58f);
var image = panel.GetComponent<Image>();
if (image != null)
image.raycastTarget = false;
var text = UiKit.CreateText(
panel,
"Stats",
string.Empty,
14,
new Color(0.86f, 0.91f, 1f),
alignment);
UiKit.Stretch(text.rectTransform);
text.rectTransform.offsetMin = new Vector2(12f, 5f);
text.rectTransform.offsetMax = new Vector2(-12f, -5f);
text.horizontalOverflow = HorizontalWrapMode.Wrap;
text.verticalOverflow = VerticalWrapMode.Overflow;
UiKit.MakeLiteral(text);
return text;
}
private void UpdateStatsOverlay()
{
if (_view == null || _selfStatsText == null || _opponentStatsText == null)
return;
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
var opponent = _view.Players.FirstOrDefault(player => player.Index != _view.You);
_selfStatsText.text = FormatStats(self, selfSide: true);
_opponentStatsText.text = FormatStats(opponent, selfSide: false);
}
private static string FormatStats(PlayerView player, bool selfSide)
{
var title = Loc.Translate(selfSide ? "ВЫ" : "ПРОТИВНИК");
if (player == null)
return title;
var damage = player.FaceDownDamage + (player.FaceUpDamage?.Count ?? 0);
var hand = Mathf.Max(player.HandCount, player.Hand?.Count ?? 0);
return title + " · " + player.Name + "\n"
+ Loc.Translate("Рука") + " " + hand
+ " " + Loc.Translate("Колода") + " " + player.DeckCount
+ " " + Loc.Translate("ДРОП") + " " + (player.Drop?.Count ?? 0)
+ " " + Loc.Translate("УРОН") + " " + damage
+ " " + Loc.Translate("Душа") + " " + (player.Soul?.Count ?? 0);
}
private void BuildSoulOverlay()
{
_soulOverlay = UiKit.CreatePanel(_root, "SoulOverlay", new Color(0f, 0f, 0f, 0.74f));

View File

@ -85,6 +85,17 @@ namespace Vanguard.UI
if (_back != null)
return _back;
var texture = Resources.Load<Texture2D>("CardArt/vanguard_back");
if (texture != null)
{
_back = Sprite.Create(
texture,
new Rect(0f, 0f, texture.width, texture.height),
new Vector2(0.5f, 0.5f),
100f);
return _back;
}
_back = BackTexture();
return _back;
}

View File

@ -51,5 +51,36 @@ namespace Vanguard.UI
Cache[imageKey] = sprite;
return sprite;
}
/// <summary>
/// "BT01/042EN" → "bt01_042". Нужно, когда каталог ещё не загрузился,
/// а арт уже лежит в Resources/CardArt.
/// </summary>
public static string ImageKeyForPrinting(string printingId)
{
if (string.IsNullOrEmpty(printingId))
return null;
var slash = printingId.IndexOf('/');
if (slash <= 0 || slash >= printingId.Length - 1)
return null;
var set = printingId.Substring(0, slash).ToLowerInvariant();
var tail = printingId.Substring(slash + 1);
var number = string.Empty;
for (var i = 0; i < tail.Length; i++)
{
var c = tail[i];
if (char.IsDigit(c) || (i == 0 && (c == 'S' || c == 's')))
{
number += char.ToLowerInvariant(c);
continue;
}
break;
}
return number.Length > 0 ? set + "_" + number : null;
}
}
}

View File

@ -38,6 +38,8 @@ namespace Vanguard.UI
["нет"] = "no",
["Вы: "] = "You: ",
["ДРОП"] = "DROP",
["Душа"] = "Soul",
["ДУША"] = "SOUL",
["Кадр"] = "Frame",
["Клан"] = "Clan",
["Пыль"] = "Dust",

View File

@ -66,6 +66,9 @@ namespace Vanguard.Tests
Assert.IsNotNull(
Resources.Load<Texture2D>("CardArt/td01_005"),
"TD01 card art is not imported");
var standardBack = Resources.Load<Texture2D>("CardArt/vanguard_back");
Assert.IsNotNull(standardBack, "Standard Vanguard card back is not imported");
Assert.AreEqual(standardBack, CardArt.Back.texture);
var artGo = new GameObject("ArtProbe", typeof(RectTransform), typeof(Image));
var artImage = artGo.GetComponent<Image>();
@ -234,6 +237,20 @@ namespace Vanguard.Tests
card.Ref.Zone == BoardZone.Guardian
&& card.Ref.InstanceId == 7));
var selfStats = Resources.FindObjectsOfTypeAll<Text>()
.FirstOrDefault(text => text.name == "Stats"
&& text.transform.parent != null
&& text.transform.parent.name == "SelfStats");
var opponentStats = Resources.FindObjectsOfTypeAll<Text>()
.FirstOrDefault(text => text.name == "Stats"
&& text.transform.parent != null
&& text.transform.parent.name == "OpponentStats");
Assert.IsNotNull(selfStats);
Assert.IsNotNull(opponentStats);
StringAssert.Contains("Рука 1", selfStats.text);
StringAssert.Contains("Душа 1", selfStats.text);
StringAssert.Contains("Рука 0", opponentStats.text);
Cleanup();
}

View File

@ -840,7 +840,8 @@ Battle; Boot и общие сервисы остаются персистент
- Раскладка: локальный игрок всегда внизу; передний ряд `RC–V–RC`, задний
`RC–RC–RC`; у противника зеркально. Между половинами — общий круг защиты
(карты обоих игроков и текущий Guard). Душа — стопка под V, колода и дроп
сбоку, урон — две колонки по три карты на боку.
сбоку, урон — две колонки по три карты на боку. Карты в V/R всегда
рисуются лицом вверх, даже если снимок пришёл с `FaceUp=false`.
- `PlaymatLayoutConfig` (ScriptableObject): размеры карт, позиции рядов,
высоты, рука, камера, палитра. Ассет:
`Runtime/Board3D/Config/PlaymatLayoutConfig.asset`.
@ -850,16 +851,21 @@ Battle; Boot и общие сервисы остаются персистент
- `Card3D` — тонкая коробка: отдельные лицо/рубашка/торец, коллайдер, арт из
`CardImageService`, переворот вокруг X, Rest вокруг Y по часовой стрелке,
урон — против часовой, плавный снап. Карты пулятся (`GameBoard3D`),
материалы общие, instancing включён.
материалы общие, instancing включён. Рубашка — стандартная VF Vanguard
`Resources/CardArt/vanguard_back.png` (источник: Card Sleeve Gallery,
`Sleeve_06.png`), общая с 2D-UI; при отсутствии файла остаётся
процедурный fallback.
- `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики,
drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и
индикатором точки броска. Ошибка интента возвращает карту на место.
- Камера приближена: позиция `(0, 6.9, -9.8)`, цель `(0, 0, -0.15)`, FOV 50.
Рука не лежит на столе, а закреплена в экране снизу по центру
(`handViewportY`, `handViewDistance`) и всегда развёрнута лицом к игроку;
при орбите камеры веер следует за полем зрения, драг из руки уходит на стол.
- `BoardCameraRig` — ПКМ орбита (±32°), СКМ панорама, колесо зум
(9.5–16); ограничения в конфиге.
- Камера фиксирована над полем: позиция `(1.4, 9, -5.2)`, цель
`(1.4, 0, -0.1)`, FOV 50; игрок её не двигает. Рука не лежит на столе,
а закреплена в экране снизу по центру (`handViewportX/Y`,
`handViewDistance`) и всегда развёрнута лицом к игроку; драг из руки
уходит на стол.
- Статистика карт вынесена в отдельные UI-панели: своя — снизу слева,
противника — сверху справа (`HAND/DECK/DROP/DMG/SOUL`); 3D-подписи
стопок и зон больше не дублируют числа.
- Душа: клик по стопке или кнопка «Душа: n» в верхней панели открывает
оверлей со списком карт.
- Песочница: `Assets/Vanguard/Scenes/GameBoard3D.unity` +

View File

@ -65,15 +65,19 @@
остаются uGUI. Карты — тонкие 3D-объекты с артом, рубашкой, торцом и
коллайдером. Rest (атака/поддержка) — поворот по часовой стрелке вокруг Y,
урон лежит на боку против часовой; закрытость — переворот вокруг X.
Рука закреплена в поле зрения камеры снизу по центру и всегда смотрит
на игрока, а не лежит на столе. Драг-н-дроп на плоскость стола с
подсветкой целей и плавным снапом; камера приближена.
Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`),
общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру
и всегда смотрит на игрока, а не лежит на столе. Камера фиксирована над
полем под небольшим углом и не двигается игроком. Драг-н-дроп на
плоскость стола с подсветкой целей и плавным снапом.
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
противника зеркально; между полями общий круг защиты; душа — стопка под
V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу,
даже если он за вторым местом. ПКМ — орбита, СКМ — панорама, колесо —
зум. Кнопка «Душа: n» в верхней панели и клик по стопке души открывают
список карт души.
даже если он за вторым местом. Карты в кругах V/R принудительно
показываются лицом вверх.
- Статистика карт — отдельное UI-наложение: своя панель снизу слева,
противника — сверху справа. Кнопка «Душа: n» в верхней панели и клик по
стопке души открывают список карт души.
- Зона урона: карты лежат открытыми и на боку; после Counter Blast
выбранная карта переворачивается рубашкой. Heal выбирает карту урона;
Counter Blast — открытую карту до активации способности.
@ -95,7 +99,7 @@
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: 3D-модели карт/стола (пока процедурные примитивы и арты),
звук, настройки (язык уже есть), DOTween, drag&drop и live-валидация
декбилдера. Камера боя — ограниченная орбита; анимации retire/ghost для
декбилдера. Камера боя фиксирована; анимации retire/ghost для
3D-карт ещё не перенесены.
## Запуск
@ -170,8 +174,8 @@ client/Assets/Vanguard/Runtime/Board3D/
Logic/BoardPlacementRules.cs Ride/Call/Move/Guard
View/GameBoard3D.cs стол, круги, пул карт, drag&drop
View/Card3D.cs карта: лицо/рубашка/торец, Rest, флип
View/BoardCameraRig.cs орбита/панорама/зум
PlaymatSandbox.cs демо без сервера
client/Assets/Vanguard/Resources/CardArt/vanguard_back.png рубашка VF
client/Assets/Vanguard/Scenes/GameBoard3D.unity песочница поля
client/Assets/Vanguard/Runtime/Battle/GameConnection.cs
client/Assets/Vanguard/Runtime/Api/ApiClient.cs + ApiContracts.cs