Build 3D playmat with drag and drop
CI / test (push) Successful in 3m58s Details

This commit is contained in:
MpakobecEx 2026-10-22 16:47:34 +05:00
parent e23116946c
commit 657859fec1
54 changed files with 4697 additions and 968 deletions

View File

@ -5,6 +5,7 @@ using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Vanguard.App;
using Vanguard.Board3D;
namespace Vanguard.Editor
{
@ -17,6 +18,9 @@ namespace Vanguard.Editor
{
private const string SceneDir = "Assets/Vanguard/Scenes";
private const string BootPath = "Assets/Scenes/Boot.unity";
private const string PlaymatConfigPath =
"Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset";
private const string PlaymatScenePath = SceneDir + "/GameBoard3D.unity";
[MenuItem("Vanguard/Rebuild Client Scenes")]
public static void BuildAll()
@ -31,6 +35,7 @@ namespace Vanguard.Editor
Create<LobbySceneController>("Lobby");
Create<DeckBuilderSceneController>("DeckBuilder");
Create<BattleSceneController>("Battle");
EnsurePlaymatConfig();
var scenes = new List<EditorBuildSettingsScene>();
Add(scenes, BootPath);
@ -46,6 +51,66 @@ namespace Vanguard.Editor
Debug.Log("Vanguard scenes rebuilt: " + scenes.Count);
}
[MenuItem("Vanguard/Rebuild Playmat Sandbox")]
public static void BuildPlaymatSandbox()
{
if (!Directory.Exists(SceneDir))
{
Directory.CreateDirectory(SceneDir);
AssetDatabase.Refresh();
}
var config = EnsurePlaymatConfig();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var cameraGo = new GameObject("Main Camera");
cameraGo.tag = "MainCamera";
var camera = cameraGo.AddComponent<Camera>();
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.02f, 0.025f, 0.045f);
camera.fieldOfView = config.cameraFov;
camera.transform.position = config.cameraPosition;
var direction = config.cameraTarget - config.cameraPosition;
if (direction.sqrMagnitude > 0.001f)
camera.transform.rotation = Quaternion.LookRotation(direction, Vector3.up);
var lightGo = new GameObject("Directional Light");
var light = lightGo.AddComponent<Light>();
light.type = LightType.Directional;
light.color = new Color(1f, 0.88f, 0.68f);
light.intensity = 1.15f;
light.shadows = LightShadows.Soft;
light.transform.rotation = Quaternion.Euler(48f, -32f, 0f);
var root = new GameObject("Playmat Sandbox");
var sandbox = root.AddComponent<PlaymatSandbox>();
sandbox.config = config;
EditorSceneManager.SaveScene(scene, PlaymatScenePath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("Vanguard playmat sandbox rebuilt: " + PlaymatScenePath);
}
private static PlaymatLayoutConfig EnsurePlaymatConfig()
{
var config = AssetDatabase.LoadAssetAtPath<PlaymatLayoutConfig>(PlaymatConfigPath);
if (config != null)
return config;
var directory = Path.GetDirectoryName(PlaymatConfigPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
AssetDatabase.Refresh();
}
config = PlaymatLayoutConfig.CreateDefault();
AssetDatabase.CreateAsset(config, PlaymatConfigPath);
AssetDatabase.SaveAssets();
return config;
}
private static void Create<T>(string sceneName) where T : MonoBehaviour
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);

View File

@ -29,6 +29,40 @@ namespace Vanguard.Editor
EditorApplication.EnterPlaymode();
}
[MenuItem("Vanguard/Play Playmat Scene Smoke")]
public static void RunPlaymat()
{
EditorSceneManager.OpenScene("Assets/Vanguard/Scenes/GameBoard3D.unity");
_started = EditorApplication.timeSinceStartup;
_checks = 0;
EditorApplication.update += TickPlaymat;
EditorApplication.EnterPlaymode();
}
private static void TickPlaymat()
{
if (!EditorApplication.isPlaying)
return;
_checks++;
if (EditorApplication.timeSinceStartup - _started < 5.0)
return;
EditorApplication.update -= TickPlaymat;
var board = UnityEngine.Object.FindAnyObjectByType<Vanguard.Board3D.GameBoard3D>();
var cards = UnityEngine.Object.FindObjectsByType<Vanguard.Board3D.Card3D>(
FindObjectsInactive.Exclude);
var success = board != null && cards.Length >= 10;
Debug.Log(
"PLAYMAT_SMOKE success=" + success
+ " board=" + (board != null)
+ " cards=" + cards.Length
+ " error=" + VanguardApp.LastInitializationError);
EditorApplication.Exit(success ? 0 : 1);
}
private static void Tick()
{
if (!EditorApplication.isPlaying)

View File

@ -51,15 +51,15 @@ namespace Vanguard.App
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = palette.Background;
camera.fieldOfView = 52f;
camera.fieldOfView = theme == SceneTheme.Battle ? 50f : 52f;
camera.transform.position = theme switch
{
SceneTheme.Battle => new Vector3(0f, 7.5f, -13f),
SceneTheme.Battle => new Vector3(0f, 8.7f, -12.4f),
SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f),
_ => new Vector3(0f, 3.2f, -10f),
};
camera.transform.rotation = theme == SceneTheme.Battle
? Quaternion.Euler(28f, 0f, 0f)
? Quaternion.Euler(34.4f, 0f, 0f)
: Quaternion.Euler(8f, 0f, 0f);
}
@ -149,14 +149,11 @@ namespace Vanguard.App
break;
case SceneTheme.Battle:
Cube(root, "Battle Table", new Vector3(0f, 0.2f, 1.5f), new Vector3(13f, 0.4f, 7f), palette.Structure);
Cube(root, "Center Line", new Vector3(0f, 0.44f, 1.5f), new Vector3(13f, 0.04f, 0.12f), palette.Accent, 1.2f);
for (var i = 0; i < 5; i++)
{
var x = (i - 2) * 2.2f;
Cube(root, "Zone " + i, new Vector3(x, 0.44f, 3.1f), new Vector3(1.8f, 0.04f, 2.2f), palette.Zone, 0.5f);
Cube(root, "Enemy Zone " + i, new Vector3(x, 0.44f, -0.1f), new Vector3(1.8f, 0.04f, 2.2f), palette.EnemyZone, 0.5f);
}
// Сам стол и круги строит GameBoard3D. Здесь только фон.
Cube(root, "Back Wall", new Vector3(0f, 4.2f, 9.5f), new Vector3(24f, 8.4f, 0.5f), palette.Structure);
Cube(root, "Pillar L", new Vector3(-8.4f, 2.5f, 4.4f), new Vector3(0.8f, 5f, 0.8f), palette.Structure);
Cube(root, "Pillar R", new Vector3(8.4f, 2.5f, 4.4f), new Vector3(0.8f, 5f, 0.8f), palette.Structure);
Cube(root, "Back Light", new Vector3(0f, 5.4f, 9f), new Vector3(6.5f, 0.16f, 0.8f), palette.Accent, 1.8f);
break;
}
}

View File

@ -18,6 +18,7 @@ namespace Vanguard.App
public const string Lobby = "Lobby";
public const string DeckBuilder = "DeckBuilder";
public const string Battle = "Battle";
public const string PlaymatSandbox = "GameBoard3D";
public DeckDto PendingQueueDeck { get; private set; }
public MatchConnectionInfo PendingMatch { get; private set; }

View File

@ -56,8 +56,12 @@ namespace Vanguard.App
private void Start()
{
if (Flow != null)
if (Flow != null
&& UnityEngine.SceneManagement.SceneManager.GetActiveScene().name
!= SceneFlow.PlaymatSandbox)
{
_ = Flow.LoadLoginAsync();
}
}
private void OnDestroy()

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee99f524fa5cd8849bef756b54365392
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 88e3b2aad35fabd47aa4f089166da98c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,65 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 65b9427316955774f9c316282cfcda9a, type: 3}
m_Name: PlaymatLayoutConfig
m_EditorClassIdentifier: Vanguard.Runtime::Vanguard.Board3D.PlaymatLayoutConfig
cardWidth: 0.63
cardHeight: 0.88
cardThickness: 0.006
circleSpacingX: 1.42
frontRowZ: 1.55
backRowZ: 3
boardHalfWidth: 4.6
boardHalfLength: 4
cardLift: 0.02
guardianLift: 0.008
damageLift: 0.012
pileLift: 0.018
dragLift: 0.14
snapRadius: 0.66
dragThresholdPixels: 7
smoothMoveSpeed: 16
smoothRotateSpeed: 14
handHoverLift: 0.08
handCenterZ: -5.3
handHeight: 1.12
handTilt: 58
handSpacing: 0.42
handMaxWidth: 7.4
handArc: 0.035
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}
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}
lineColor: {r: 0.62, g: 0.45, b: 0.18, a: 1}
ringColor: {r: 0.18, g: 0.28, b: 0.44, a: 1}
vanguardRingColor: {r: 0.78, g: 0.6, b: 0.24, a: 1}
guardianColor: {r: 0.18, g: 0.58, b: 0.7, a: 1}
damageColor: {r: 0.58, g: 0.16, b: 0.18, a: 1}
soulColor: {r: 0.42, g: 0.25, b: 0.68, a: 1}
highlightColor: {r: 1, g: 0.78, b: 0.24, a: 1}
selectedColor: {r: 0.35, g: 0.95, b: 0.56, a: 1}
cardPrefab: {fileID: 0}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c1f4dc13d22d0004ba1bddedecf71461
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,138 @@
using UnityEngine;
namespace Vanguard.Board3D
{
/// <summary>
/// Настройка 3D-поля: геометрия кругов, высоты, скорости анимаций,
/// камера и палитра. Один общий ассет на Classic; раскладка не зависит
/// от того, за какое место играет локальный игрок.
/// </summary>
[CreateAssetMenu(
menuName = "Vanguard/Playmat Layout",
fileName = "PlaymatLayoutConfig")]
public sealed class PlaymatLayoutConfig : ScriptableObject
{
[Header("Карта")]
public float cardWidth = 0.63f;
public float cardHeight = 0.88f;
public float cardThickness = 0.006f;
[Header("Поле")]
[Tooltip("Расстояние между центрами соседних кругов по X.")]
public float circleSpacingX = 1.42f;
[Tooltip("Z переднего ряда в локальных координатах игрока.")]
public float frontRowZ = 1.55f;
[Tooltip("Z заднего ряда в локальных координатах игрока.")]
public float backRowZ = 3.0f;
public float boardHalfWidth = 4.6f;
public float boardHalfLength = 4.0f;
[Header("Высоты")]
public float cardLift = 0.02f;
public float guardianLift = 0.008f;
public float damageLift = 0.012f;
public float pileLift = 0.018f;
public float dragLift = 0.14f;
[Header("Взаимодействие")]
public float snapRadius = 0.66f;
public float dragThresholdPixels = 7f;
public float smoothMoveSpeed = 16f;
public float smoothRotateSpeed = 14f;
public float handHoverLift = 0.08f;
[Header("Рука")]
public float handCenterZ = -5.3f;
public float handHeight = 1.12f;
[Range(0f, 85f)] public float handTilt = 58f;
public float handSpacing = 0.42f;
public float handMaxWidth = 7.4f;
public float handArc = 0.035f;
[Header("Круг защиты")]
public float guardianRowGap = 0.55f;
public float guardianSpacing = 0.44f;
public float guardianMaxWidth = 3.6f;
[Header("Стопки")]
[Tooltip("Расстояние от центра стола до зоны урона.")]
public float damageX = 3.5f;
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
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 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);
public Color matSelfColor = new Color(0.055f, 0.095f, 0.16f);
public Color matOpponentColor = new Color(0.13f, 0.065f, 0.075f);
public Color lineColor = new Color(0.62f, 0.45f, 0.18f);
public Color ringColor = new Color(0.18f, 0.28f, 0.44f);
public Color vanguardRingColor = new Color(0.78f, 0.60f, 0.24f);
public Color guardianColor = new Color(0.18f, 0.58f, 0.70f);
public Color damageColor = new Color(0.58f, 0.16f, 0.18f);
public Color soulColor = new Color(0.42f, 0.25f, 0.68f);
public Color highlightColor = new Color(1f, 0.78f, 0.24f);
public Color selectedColor = new Color(0.35f, 0.95f, 0.56f);
[Header("Необязательный prefab карты")]
public Card3D cardPrefab;
private static PlaymatLayoutConfig _default;
/// <summary>Рантайм-конфиг, если в сцене нет ассета.</summary>
public static PlaymatLayoutConfig Default
{
get
{
if (_default == null)
{
_default = CreateInstance<PlaymatLayoutConfig>();
_default.name = "PlaymatLayoutConfig (runtime)";
_default.hideFlags = HideFlags.DontSave;
}
return _default;
}
}
public static PlaymatLayoutConfig CreateDefault()
{
var config = CreateInstance<PlaymatLayoutConfig>();
config.name = "PlaymatLayoutConfig";
return config;
}
public float CardCenterY => cardLift + (cardThickness * 0.5f);
public float GuardianCenterY => guardianLift + (cardThickness * 0.5f);
public float DamageCenterY => damageLift + (cardThickness * 0.5f);
private void OnValidate()
{
cardWidth = Mathf.Max(0.1f, cardWidth);
cardHeight = Mathf.Max(0.1f, cardHeight);
cardThickness = Mathf.Max(0.001f, cardThickness);
circleSpacingX = Mathf.Max(0.4f, circleSpacingX);
frontRowZ = Mathf.Max(0.2f, frontRowZ);
backRowZ = Mathf.Max(frontRowZ + 0.2f, backRowZ);
boardHalfWidth = Mathf.Max(2f, boardHalfWidth);
boardHalfLength = Mathf.Max(backRowZ + 0.5f, boardHalfLength);
dragLift = Mathf.Max(cardLift + 0.02f, dragLift);
snapRadius = Mathf.Clamp(snapRadius, 0.2f, 1.5f);
handMaxWidth = Mathf.Max(1f, handMaxWidth);
guardianMaxWidth = Mathf.Max(1f, guardianMaxWidth);
orbitPitchMax = Mathf.Clamp(orbitPitchMax, orbitPitchMin + 1f, 85f);
zoomMax = Mathf.Max(zoomMin + 0.5f, zoomMax);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65b9427316955774f9c316282cfcda9a

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d0b844da79953df4bbe6319c79b3105a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,241 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Vanguard.Shared.Protocol;
namespace Vanguard.Board3D
{
/// <summary>Круг поля. Classic использует V/R, остальные — задел на будущее.</summary>
public enum CircleAttribute
{
Vanguard,
RearGuard,
Guardian,
Damage,
Soul,
Deck,
Drop,
Hand,
Bind,
GenerationBreak,
}
/// <summary>Зона, к которой относится карта или пустой круг.</summary>
public enum BoardZone
{
Vanguard,
RearGuard,
Guardian,
Damage,
Soul,
Deck,
Drop,
Hand,
Bind,
GenerationBreak,
}
/// <summary>Положение карты на столе: открыта/закрыта и стоит/отдыхает.</summary>
public readonly struct CardState3D
{
public CardState3D(bool faceUp, bool rested)
{
FaceUp = faceUp;
Rested = rested;
}
public bool FaceUp { get; }
public bool Rested { get; }
public static CardState3D FaceUpStand => new CardState3D(true, false);
public static CardState3D FaceDownStand => new CardState3D(false, false);
public static CardState3D FaceUpRest => new CardState3D(true, true);
public static CardState3D FaceDownRest => new CardState3D(false, true);
public override string ToString() =>
(FaceUp ? "up" : "down") + "/" + (Rested ? "rest" : "stand");
}
/// <summary>Ключ круга для подсветки. Slot = -1 — Авангард.</summary>
public readonly struct BoardSlotKey : IEquatable<BoardSlotKey>
{
public BoardSlotKey(int player, int slot)
{
Player = player;
Slot = slot;
}
public int Player { get; }
public int Slot { get; }
public bool Equals(BoardSlotKey other) =>
Player == other.Player && Slot == other.Slot;
public override bool Equals(object obj) =>
obj is BoardSlotKey other && Equals(other);
public override int GetHashCode() => (Player * 31) + Slot;
public override string ToString() => Player + ":" + Slot;
}
/// <summary>
/// Ссылка на объект стола. Для поля заполнен Slot, для руки/урона/защиты —
/// Index. InstanceId = 0 у пустого круга и закрытой карты урона.
/// </summary>
public readonly struct BoardCardRef : IEquatable<BoardCardRef>
{
public BoardCardRef(
BoardZone zone,
int player,
int slot,
int index,
int instanceId,
string printingId,
bool faceUp)
{
Zone = zone;
Player = player;
Slot = slot;
Index = index;
InstanceId = instanceId;
PrintingId = printingId;
FaceUp = faceUp;
}
public BoardZone Zone { get; }
public int Player { get; }
public int Slot { get; }
public int Index { get; }
public int InstanceId { get; }
public string PrintingId { get; }
public bool FaceUp { get; }
public bool IsField => Zone is BoardZone.Vanguard or BoardZone.RearGuard;
public bool IsHand => Zone == BoardZone.Hand;
public bool IsEmpty => InstanceId == 0;
public BoardSlotKey SlotKey => new BoardSlotKey(Player, Slot);
public static BoardCardRef Field(int player, int slot, CardView card) =>
new BoardCardRef(
slot < 0 ? BoardZone.Vanguard : BoardZone.RearGuard,
player,
slot,
0,
card != null ? card.InstanceId : 0,
card != null ? card.PrintingId : null,
card != null && card.FaceUp);
public static BoardCardRef Hand(int player, int index, CardView card) =>
new BoardCardRef(
BoardZone.Hand,
player,
-2,
index,
card != null ? card.InstanceId : 0,
card != null ? card.PrintingId : null,
card != null && card.FaceUp);
public static BoardCardRef Guardian(int player, int index, CardView card) =>
new BoardCardRef(
BoardZone.Guardian,
player,
-3,
index,
card != null ? card.InstanceId : 0,
card != null ? card.PrintingId : null,
card != null && card.FaceUp);
public static BoardCardRef Damage(int player, int index, CardView card, bool faceUp) =>
new BoardCardRef(
BoardZone.Damage,
player,
-4,
index,
card != null ? card.InstanceId : 0,
card != null ? card.PrintingId : null,
faceUp);
public static BoardCardRef Pile(BoardZone zone, int player, int count) =>
new BoardCardRef(zone, player, -5, count, 0, null, false);
public static BoardCardRef EmptySlot(int player, int slot) =>
new BoardCardRef(
slot < 0 ? BoardZone.Vanguard : BoardZone.RearGuard,
player,
slot,
0,
0,
null,
true);
public bool Equals(BoardCardRef other) =>
Zone == other.Zone
&& Player == other.Player
&& Slot == other.Slot
&& Index == other.Index
&& InstanceId == other.InstanceId;
public override bool Equals(object obj) =>
obj is BoardCardRef other && Equals(other);
public override int GetHashCode() =>
(((int)Zone * 397) ^ Player * 31 ^ Slot) * 31 ^ Index ^ InstanceId;
public override string ToString() =>
Zone + ":" + Player + ":" + (IsField ? Slot.ToString() : Index.ToString());
}
/// <summary>Куда можно уронить карту при drag&amp;drop.</summary>
public readonly struct BoardDropTarget
{
public BoardDropTarget(BoardZone zone, int player, int slot, int index)
{
Zone = zone;
Player = player;
Slot = slot;
Index = index;
}
public BoardZone Zone { get; }
public int Player { get; }
public int Slot { get; }
public int Index { get; }
public BoardCardRef ToRef() => new BoardCardRef(
Zone, Player, Slot, Index, 0, null, true);
}
/// <summary>Временное состояние отрисовки, которое приходит из боевого экрана.</summary>
public sealed class BoardRenderState
{
public HashSet<BoardSlotKey> Highlights { get; } = new HashSet<BoardSlotKey>();
public HashSet<BoardSlotKey> DropTargets { get; } = new HashSet<BoardSlotKey>();
public bool GuardianDropAllowed { get; set; }
public HashSet<int> SelectedHandInstanceIds { get; } = new HashSet<int>();
public bool MulliganSelecting { get; set; }
public bool DamageChoice { get; set; }
public bool AbilityDamageTargeting { get; set; }
public int AbilityDamageIndex { get; set; } = -1;
public bool MatchFinished { get; set; }
}
/// <summary>Текстура лица и акцентный цвет карты. Front может быть null.</summary>
public sealed class BoardArt
{
public BoardArt(Texture2D front, Color accent, string displayName = null)
{
Front = front;
Accent = accent;
DisplayName = displayName;
}
public Texture2D Front { get; }
public Color Accent { get; }
public string DisplayName { get; }
public static BoardArt Fallback(Color accent) => new BoardArt(null, accent);
}
public delegate BoardArt BoardArtProvider(string printingId);
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d62441ca1c992648925ecfd2beafd34

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 94d93f21bd6f5bd4b94fda0ef65ea27d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,56 @@
namespace Vanguard.Board3D
{
/// <summary>
/// Локальные правила раскладки для UX. Сервер остаётся авторитарным:
/// эти проверки нужны для подсветки и drag&amp;drop, а не для начисления
/// результата.
/// </summary>
public static class BoardPlacementRules
{
public static bool CanRide(BoardZone zone, int slot) =>
zone == BoardZone.Vanguard && slot == PlaymatLayout.VanguardSlot;
public static bool CanCall(int slot, bool occupied) =>
slot >= 0
&& slot < PlaymatLayout.RearGuardCount
&& !occupied;
public static bool CanGuard(BoardZone zone) =>
zone == BoardZone.Guardian;
public static bool CanMove(int fromSlot, int toSlot) =>
fromSlot >= 0
&& fromSlot < PlaymatLayout.RearGuardCount
&& toSlot >= 0
&& toSlot < PlaymatLayout.RearGuardCount
&& fromSlot != toSlot
&& SameColumn(fromSlot, toSlot);
public static bool CanAttack(int attackerSlot, int targetSlot) =>
attackerSlot >= PlaymatLayout.VanguardSlot
&& attackerSlot < PlaymatLayout.RearGuardCount
&& targetSlot >= PlaymatLayout.VanguardSlot
&& targetSlot < PlaymatLayout.RearGuardCount;
/// <summary>Колонки Classic: левая 0↔3, правая 2↔4; у центра пары нет.</summary>
public static bool SameColumn(int fromSlot, int toSlot) =>
(fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2);
/// <summary>Call и Ride входят стоя и открытыми.</summary>
public static CardState3D EnterFromHand(CircleAttribute circle) => circle switch
{
CircleAttribute.Vanguard => CardState3D.FaceUpStand,
CircleAttribute.RearGuard => CardState3D.FaceUpStand,
CircleAttribute.Guardian => CardState3D.FaceUpStand,
_ => CardState3D.FaceUpStand,
};
/// <summary>Перемещение RC→RC сохраняет Rest и открытость.</summary>
public static CardState3D PreserveOnMove(CardState3D state) => state;
public static BoardZone ZoneOfSlot(int slot) =>
slot == PlaymatLayout.VanguardSlot
? BoardZone.Vanguard
: BoardZone.RearGuard;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 511bb3e3238227e4eb651ed4d7d5d386

View File

@ -0,0 +1,215 @@
using UnityEngine;
namespace Vanguard.Board3D
{
/// <summary>
/// Геометрия стола. Локальное место 0 — ближняя половина (локальный
/// игрок), 1 — дальняя (противник). Позиции всегда зеркальны, поэтому
/// игрок за второе место видит своё поле внизу.
/// </summary>
public sealed class PlaymatLayout
{
public const int VanguardSlot = -1;
public const int RearGuardCount = 5;
public const int DamageSlots = 6;
private readonly PlaymatLayoutConfig _config;
public PlaymatLayout(PlaymatLayoutConfig config)
{
_config = config != null ? config : PlaymatLayoutConfig.Default;
}
public PlaymatLayoutConfig Config => _config;
public int ToLocalSeat(int player, int selfPlayer) =>
player == selfPlayer ? 0 : 1;
public int ToPlayer(int localSeat, int selfPlayer) =>
localSeat == 0 ? selfPlayer : 1 - selfPlayer;
/// <summary>Z-направление от центра к половине локального места.</summary>
public float ZSign(int localSeat) => localSeat == 0 ? -1f : 1f;
/// <summary>Поворот лица карты: своя половина читается с -Z, чужая — с +Z.</summary>
public float BaseYaw(int localSeat) => localSeat == 0 ? 0f : 180f;
public Vector3 CirclePosition(int localSeat, int slot)
{
var zSign = ZSign(localSeat);
var columnSign = localSeat == 0 ? 1f : -1f;
var x = 0f;
var z = slot == VanguardSlot || slot is 3 or 4
? zSign * _config.frontRowZ
: zSign * _config.backRowZ;
switch (slot)
{
case 0:
case 3:
x = -_config.circleSpacingX * columnSign;
break;
case 2:
case 4:
x = _config.circleSpacingX * columnSign;
break;
}
return new Vector3(x, _config.CardCenterY, z);
}
public Vector3 CirclePositionForPlayer(int player, int selfPlayer, int slot) =>
CirclePosition(ToLocalSeat(player, selfPlayer), slot);
/// <summary>Круги урона: две колонки по три карты на боку.</summary>
public Vector3 DamagePosition(int localSeat, int index)
{
var zSign = ZSign(localSeat);
var sideSign = localSeat == 0 ? -1f : 1f;
var innerSign = localSeat == 0 ? -1f : 1f;
var xCenter = sideSign * _config.damageX;
var x = xCenter + (index % 2 == 0 ? innerSign : -innerSign) * 0.42f;
var z = zSign * (_config.frontRowZ + 0.45f)
+ zSign * (index / 2) * 0.55f;
return new Vector3(x, _config.DamageCenterY, z);
}
/// <summary>Колода и дроп сбоку от поля, у своего края.</summary>
public Vector3 DeckPosition(int localSeat)
{
var x = (localSeat == 0 ? 1f : -1f) * _config.pileX;
var z = ZSign(localSeat) * (_config.backRowZ + 0.15f);
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);
return new Vector3(x, _config.pileLift, z);
}
/// <summary>Душа — визуальная стопка под кругом V, чуть ближе к игроку.</summary>
public Vector3 SoulPosition(int localSeat)
{
var z = ZSign(localSeat) * (_config.frontRowZ + 0.55f);
return new Vector3(0f, 0.002f, z);
}
/// <summary>Общий круг защиты: своя половина ближе к своему полю.</summary>
public Vector3 GuardianPosition(int localSeat, int index, int count)
{
var spacing = _config.guardianSpacing;
if (count > 1)
spacing = Mathf.Min(spacing, _config.guardianMaxWidth / (count - 1));
var offset = index - (count - 1) * 0.5f;
var z = ZSign(localSeat) * _config.guardianRowGap;
return new Vector3(offset * spacing, _config.GuardianCenterY, z);
}
/// <summary>Веер руки перед локальным игроком.</summary>
public HandPose GetHandPose(int index, int count)
{
var safeCount = Mathf.Max(1, count);
var spacing = _config.handSpacing;
if (safeCount > 1)
spacing = Mathf.Min(spacing, _config.handMaxWidth / (safeCount - 1));
var offset = index - (safeCount - 1) * 0.5f;
var normalized = offset / safeCount;
var x = offset * spacing;
var z = _config.handCenterZ + Mathf.Abs(offset) * _config.handArc;
var y = _config.handHeight + (1f - Mathf.Abs(normalized)) * 0.02f;
var yaw = -offset * 3.2f;
return new HandPose(new Vector3(x, y, z), yaw);
}
/// <summary>
/// Ближайшая цель для броска карты. Возвращает false, если карта
/// отпущена мимо кругов и общего круга защиты.
/// </summary>
public bool TryFindDropTarget(
Vector3 worldPoint,
int selfPlayer,
out BoardDropTarget target)
{
var bestDistance = float.MaxValue;
target = default;
for (var localSeat = 0; localSeat < 2; localSeat++)
{
for (var slot = VanguardSlot; slot < RearGuardCount; slot++)
{
var position = CirclePosition(localSeat, slot);
var distance = XzDistance(worldPoint, position);
if (distance > _config.snapRadius || distance >= bestDistance)
continue;
bestDistance = distance;
target = new BoardDropTarget(
slot == VanguardSlot ? BoardZone.Vanguard : BoardZone.RearGuard,
ToPlayer(localSeat, selfPlayer),
slot,
0);
}
}
if (bestDistance < float.MaxValue)
return true;
// Общий круг защиты принимает карты с любой стороны.
if (Mathf.Abs(worldPoint.x) <= _config.guardianMaxWidth * 0.5f + 0.35f
&& Mathf.Abs(worldPoint.z) <= _config.guardianRowGap + 0.45f)
{
var localSeat = worldPoint.z < 0f ? 0 : 1;
target = new BoardDropTarget(
BoardZone.Guardian,
ToPlayer(localSeat, selfPlayer),
-3,
0);
return true;
}
return false;
}
public static float XzDistance(Vector3 a, Vector3 b)
{
var dx = a.x - b.x;
var dz = a.z - b.z;
return Mathf.Sqrt((dx * dx) + (dz * dz));
}
public static bool IsFrontRow(int slot) => slot is 3 or 4;
public static bool IsBackRow(int slot) => slot is 0 or 1 or 2;
public static string SlotLabel(int slot)
{
if (slot == VanguardSlot)
return "V";
return slot switch
{
0 => "RC1",
1 => "RC2",
2 => "RC3",
3 => "RC4",
4 => "RC5",
_ => "?",
};
}
public readonly struct HandPose
{
public HandPose(Vector3 position, float yaw)
{
Position = position;
Yaw = yaw;
}
public Vector3 Position { get; }
public float Yaw { get; }
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ccfc6e38ed67c204f9b17de5edccbf6a

View File

@ -0,0 +1,165 @@
using System.Collections.Generic;
using UnityEngine;
using Vanguard.App;
using Vanguard.Shared.Protocol;
using Vanguard.UI;
namespace Vanguard.Board3D
{
/// <summary>
/// Демо-сцена 3D-поля: раскладывает карты без сервера, чтобы проверять
/// геометрию, drag&amp;drop, rest, закрытые карты и общий круг защиты.
/// </summary>
public sealed class PlaymatSandbox : MonoBehaviour
{
public PlaymatLayoutConfig config;
public bool showHints = true;
private GameBoard3D _board;
private int _clicks;
private void Start()
{
SceneEnvironment.Build(SceneTheme.Battle);
_board = GameBoard3D.Create(null, config != null ? config : PlaymatLayoutConfig.Default);
_board.ArtProvider = SandboxArt;
_board.SetVisible(true);
_board.CardClicked += OnCardClicked;
_board.SlotClicked += OnSlotClicked;
_board.CardDropped += OnCardDropped;
_board.PileClicked += OnPileClicked;
_board.Render(BuildDemo(), BuildState());
}
private void OnCardClicked(BoardCardRef card)
{
_clicks++;
Debug.Log("SANDBOX card " + card);
}
private void OnSlotClicked(BoardCardRef slot)
{
Debug.Log("SANDBOX slot " + slot);
}
private void OnCardDropped(BoardCardRef source, BoardCardRef target)
{
Debug.Log("SANDBOX drop " + source + " -> " + target);
// В песочнице нет сервера: возвращаем карту на место.
_board.Render(BuildDemo(), BuildState());
}
private void OnPileClicked(BoardCardRef pile)
{
Debug.Log("SANDBOX pile " + pile + " count=" + pile.Index);
}
private static BoardArt SandboxArt(string printingId)
{
var clan = printingId != null && printingId.Contains("002")
? "Kagero"
: "RoyalPaladin";
var sprite = CardArt.ForCard(printingId ?? "sandbox", clan);
return new BoardArt(
sprite != null ? sprite.texture : null,
CardArt.ClanColor(clan),
printingId);
}
private static BoardRenderState BuildState()
{
var state = new BoardRenderState();
state.Highlights.Add(new BoardSlotKey(0, -1));
state.Highlights.Add(new BoardSlotKey(1, -1));
state.SelectedHandInstanceIds.Add(12);
state.DropTargets.Add(new BoardSlotKey(0, -1));
for (var slot = 0; slot < PlaymatLayout.RearGuardCount; slot++)
state.DropTargets.Add(new BoardSlotKey(0, slot));
state.GuardianDropAllowed = true;
return state;
}
private static MatchView BuildDemo()
{
CardView Card(string id, int instance, bool rested = false, bool faceUp = true) =>
new CardView
{
PrintingId = id,
InstanceId = instance,
Rested = rested,
FaceUp = faceUp,
BasePower = 8000,
Power = rested ? 5000 : 8000,
BaseCritical = 1,
Critical = 1,
};
var self = new PlayerView
{
Index = 0,
Name = "Sandbox",
Vanguard = Card("BT01/001EN", 1),
RearGuards = new CardView[5],
Hand = new List<CardView>
{
Card("BT01/002EN", 11),
Card("BT01/003EN", 12),
Card("BT01/004EN", 13),
Card("BT01/005EN", 14),
Card("BT01/006EN", 15),
},
Guardian = new List<CardView>
{
Card("BT01/007EN", 21),
Card("BT01/008EN", 22),
},
Soul = new List<CardView>
{
Card("BT01/009EN", 31),
Card("BT01/010EN", 32),
},
FaceDownDamage = 2,
FaceUpDamage = new List<CardView>
{
Card("BT01/011EN", 41),
Card("BT01/012EN", 42),
},
DeckCount = 38,
Drop = new List<CardView> { Card("BT01/013EN", 51) },
};
self.RearGuards[0] = Card("BT01/014EN", 2);
self.RearGuards[3] = Card("BT01/015EN", 3, rested: true);
self.RearGuards[4] = Card("BT01/016EN", 4);
var opponent = new PlayerView
{
Index = 1,
Name = "Opponent",
Vanguard = Card("BT01/020EN", 101),
RearGuards = new CardView[5],
Hand = new List<CardView>(),
Guardian = new List<CardView>(),
Soul = new List<CardView> { Card("BT01/021EN", 131) },
FaceDownDamage = 1,
FaceUpDamage = new List<CardView> { Card("BT01/022EN", 141) },
DeckCount = 40,
Drop = new List<CardView>(),
};
opponent.RearGuards[4] = Card("BT01/023EN", 102);
opponent.RearGuards[2] = Card("BT01/024EN", 103, rested: true);
return new MatchView
{
MatchId = "sandbox",
You = 0,
ActivePlayer = 0,
TurnNumber = 4,
Phase = GamePhase.Main,
Players = new List<PlayerView> { self, opponent },
Clock = new ClockView(),
LegalIntents = new List<IntentPayload>(),
};
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 89fedfc3c1f371c448b4187e210d36fb

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1db33b73c2d4ad645b7ad7a7e6aaff6b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,132 @@
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

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

View File

@ -0,0 +1,151 @@
using UnityEngine;
using UnityEngine.Rendering;
namespace Vanguard.Board3D
{
/// <summary>Общие материалы стола. Текстуры лиц задаются property block.</summary>
internal static class BoardMaterials
{
private static Material _edge;
private static Material _back;
private static Material _highlight;
private static Material _white;
public static Material Edge
{
get
{
if (_edge == null)
_edge = CreateLit(new Color(0.10f, 0.11f, 0.14f));
return _edge;
}
}
public static Material Back
{
get
{
if (_back == null)
_back = CreateUnlit(BoardTextures.Back, Color.white);
return _back;
}
}
public static Material Highlight
{
get
{
if (_highlight == null)
_highlight = CreateUnlit(null, new Color(1f, 0.78f, 0.24f));
return _highlight;
}
}
public static Material White
{
get
{
if (_white == null)
_white = CreateUnlit(null, Color.white);
return _white;
}
}
public static Material CreateLit(Color color, float emission = 0f)
{
var shader = Shader.Find("Universal Render Pipeline/Lit");
if (shader == null)
shader = Shader.Find("Standard");
if (shader == null)
return CreateUnlit(null, color);
var material = new Material(shader)
{
name = "Board Lit",
enableInstancing = true,
hideFlags = HideFlags.DontSave,
};
SetColor(material, color);
if (emission > 0f && material.HasProperty("_EmissionColor"))
{
material.EnableKeyword("_EMISSION");
material.SetColor("_EmissionColor", color * emission);
}
return material;
}
public static Material CreateUnlit(Texture texture, Color tint)
{
var shader = Shader.Find("Universal Render Pipeline/Unlit");
if (shader == null)
shader = Shader.Find("Unlit/Texture");
if (shader == null)
shader = Shader.Find("Sprites/Default");
if (shader == null)
shader = Shader.Find("Standard");
var material = new Material(shader)
{
name = "Board Unlit",
enableInstancing = true,
hideFlags = HideFlags.DontSave,
};
if (texture != null)
{
if (material.HasProperty("_BaseMap"))
material.SetTexture("_BaseMap", texture);
if (material.HasProperty("_MainTex"))
material.SetTexture("_MainTex", texture);
}
SetColor(material, tint);
if (material.HasProperty("_Surface"))
material.SetFloat("_Surface", 0f);
if (material.HasProperty("_Blend"))
material.SetFloat("_Blend", 0f);
return material;
}
public static void SetColor(Material material, Color color)
{
if (material == null)
return;
if (material.HasProperty("_BaseColor"))
material.SetColor("_BaseColor", color);
if (material.HasProperty("_Color"))
material.SetColor("_Color", color);
}
public static void SetColor(MaterialPropertyBlock block, Material material, Color color)
{
if (block == null)
return;
if (material != null && material.HasProperty("_BaseColor"))
block.SetColor("_BaseColor", color);
else
block.SetColor("_Color", color);
}
public static void SetTexture(MaterialPropertyBlock block, Material material, Texture texture)
{
if (block == null)
return;
if (material != null && material.HasProperty("_BaseMap"))
block.SetTexture("_BaseMap", texture);
else
block.SetTexture("_MainTex", texture);
}
public static void SetShadows(Renderer renderer, bool cast)
{
if (renderer == null)
return;
renderer.shadowCastingMode = cast
? ShadowCastingMode.On
: ShadowCastingMode.Off;
renderer.receiveShadows = cast;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ee026e3b2fff0b548b3a06f4c6daab77

View File

@ -0,0 +1,164 @@
using System.Collections.Generic;
using UnityEngine;
namespace Vanguard.Board3D
{
/// <summary>Плоские рамки кругов и зон в плоскости XZ.</summary>
internal static class BoardMeshLibrary
{
private static readonly Dictionary<string, Mesh> Cache =
new Dictionary<string, Mesh>();
public static Mesh Frame(float width, float depth, float border)
{
var key = Mathf.RoundToInt(width * 1000f)
+ ":" + Mathf.RoundToInt(depth * 1000f)
+ ":" + Mathf.RoundToInt(border * 1000f);
if (Cache.TryGetValue(key, out var cached) && cached != null)
return cached;
var mesh = BuildFrame(width, depth, border);
mesh.hideFlags = HideFlags.DontSave;
Cache[key] = mesh;
return mesh;
}
public static Mesh CardFace(float width, float height, bool front)
{
var key = (front ? "front:" : "back:")
+ Mathf.RoundToInt(width * 1000f)
+ ":" + Mathf.RoundToInt(height * 1000f);
if (Cache.TryGetValue(key, out var cached) && cached != null)
return cached;
var halfW = width * 0.5f;
var halfH = height * 0.5f;
var normal = front ? Vector3.up : Vector3.down;
var mesh = new Mesh
{
name = front ? "Card Front" : "Card Back",
hideFlags = HideFlags.DontSave,
vertices = new[]
{
new Vector3(-halfW, 0f, -halfH),
new Vector3(halfW, 0f, -halfH),
new Vector3(halfW, 0f, halfH),
new Vector3(-halfW, 0f, halfH),
},
uv = front
? 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[]
{
0, 1, 2, 0, 2, 3,
0, 2, 1, 0, 3, 2,
},
};
mesh.RecalculateBounds();
Cache[key] = mesh;
return mesh;
}
private static Mesh BuildFrame(float width, float depth, float border)
{
var halfW = width * 0.5f;
var halfD = depth * 0.5f;
border = Mathf.Clamp(border, 0.005f, Mathf.Min(halfW, halfD) * 0.9f);
var vertices = new List<Vector3>(16);
var triangles = new List<int>(48);
var uvs = new List<Vector2>(16);
AddQuad(
vertices, triangles, uvs,
new Vector3(-halfW, 0f, halfD - border),
new Vector3(halfW, 0f, halfD - border),
new Vector3(halfW, 0f, halfD),
new Vector3(-halfW, 0f, halfD));
AddQuad(
vertices, triangles, uvs,
new Vector3(-halfW, 0f, -halfD),
new Vector3(halfW, 0f, -halfD),
new Vector3(halfW, 0f, -halfD + border),
new Vector3(-halfW, 0f, -halfD + border));
AddQuad(
vertices, triangles, uvs,
new Vector3(-halfW, 0f, -halfD + border),
new Vector3(-halfW + border, 0f, -halfD + border),
new Vector3(-halfW + border, 0f, halfD - border),
new Vector3(-halfW, 0f, halfD - border));
AddQuad(
vertices, triangles, uvs,
new Vector3(halfW - border, 0f, -halfD + border),
new Vector3(halfW, 0f, -halfD + border),
new Vector3(halfW, 0f, halfD - border),
new Vector3(halfW - border, 0f, halfD - border));
var mesh = new Mesh
{
name = "Board Frame",
vertices = vertices.ToArray(),
triangles = triangles.ToArray(),
uv = uvs.ToArray(),
};
var normals = new Vector3[vertices.Count];
for (var i = 0; i < normals.Length; i++)
normals[i] = Vector3.up;
mesh.normals = normals;
mesh.RecalculateBounds();
return mesh;
}
private static void AddQuad(
ICollection<Vector3> vertices,
ICollection<int> triangles,
ICollection<Vector2> uvs,
Vector3 a,
Vector3 b,
Vector3 c,
Vector3 d)
{
var start = vertices.Count;
vertices.Add(a);
vertices.Add(b);
vertices.Add(c);
vertices.Add(d);
uvs.Add(new Vector2(0f, 0f));
uvs.Add(new Vector2(1f, 0f));
uvs.Add(new Vector2(1f, 1f));
uvs.Add(new Vector2(0f, 1f));
// Две стороны: рамки видны и сверху, и при наклоне камеры.
triangles.Add(start);
triangles.Add(start + 1);
triangles.Add(start + 2);
triangles.Add(start);
triangles.Add(start + 2);
triangles.Add(start + 3);
triangles.Add(start);
triangles.Add(start + 2);
triangles.Add(start + 1);
triangles.Add(start);
triangles.Add(start + 3);
triangles.Add(start + 2);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b08a1484f6888564c83043946c9467b6

View File

@ -0,0 +1,100 @@
using UnityEngine;
namespace Vanguard.Board3D
{
/// <summary>Круг поля: рамка, коллайдер и подпись V/R.</summary>
public sealed class BoardSlotView : MonoBehaviour
{
private MeshRenderer _renderer;
private MaterialPropertyBlock _block;
private Color _baseColor;
private Color _highlightColor;
public int Player { get; private set; }
public int Slot { get; private set; }
public int LocalSeat { get; private set; }
public BoardSlotKey Key => new BoardSlotKey(Player, Slot);
public BoardCardRef Ref => BoardCardRef.EmptySlot(Player, Slot);
public void SetPlayer(int player)
{
Player = player;
}
public static BoardSlotView Create(
Transform parent,
PlaymatLayoutConfig config,
int localSeat,
int player,
int slot,
PlaymatLayout layout)
{
var go = new GameObject("Slot " + PlaymatLayout.SlotLabel(slot));
go.transform.SetParent(parent, false);
go.transform.localPosition = layout.CirclePosition(localSeat, slot);
go.transform.localPosition = new Vector3(
go.transform.localPosition.x,
0.012f,
go.transform.localPosition.z);
var view = go.AddComponent<BoardSlotView>();
view.Player = player;
view.Slot = slot;
view.LocalSeat = localSeat;
view._baseColor = slot == PlaymatLayout.VanguardSlot
? config.vanguardRingColor
: config.ringColor;
view._highlightColor = config.highlightColor;
view._block = new MaterialPropertyBlock();
var filter = go.AddComponent<MeshFilter>();
filter.sharedMesh = BoardMeshLibrary.Frame(
config.cardWidth * 1.34f,
config.cardHeight * 1.34f,
Mathf.Max(0.018f, config.cardWidth * 0.035f));
var renderer = go.AddComponent<MeshRenderer>();
renderer.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(renderer, false);
view._renderer = renderer;
view.ApplyColor(false);
var collider = go.AddComponent<BoxCollider>();
collider.size = new Vector3(
config.cardWidth * 1.12f,
0.02f,
config.cardHeight * 1.12f);
collider.center = Vector3.zero;
var label = slot == PlaymatLayout.VanguardSlot ? "V" : "R";
var text = BoardText.Create(
go.transform,
label,
0.14f,
new Color(1f, 1f, 1f, 0.34f),
new Vector3(0f, 0.001f, 0f));
text.name = "Slot Label";
return view;
}
public void SetHighlighted(bool highlighted)
{
ApplyColor(highlighted);
}
private void ApplyColor(bool highlighted)
{
if (_renderer == null)
return;
_renderer.GetPropertyBlock(_block);
_block.Clear();
BoardMaterials.SetColor(
_block,
_renderer.sharedMaterial,
highlighted ? _highlightColor : _baseColor);
_renderer.SetPropertyBlock(_block);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69d07f80399a37a4b9faef94f0c43fde

View File

@ -0,0 +1,73 @@
using UnityEngine;
using UnityEngine.Rendering;
namespace Vanguard.Board3D
{
/// <summary>Плоские подписи на столе без TMP и Canvas.</summary>
internal static class BoardText
{
private static Font _font;
public static Font Font
{
get
{
if (_font != null)
return _font;
_font = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
if (_font == null)
_font = Resources.GetBuiltinResource<Font>("Arial.ttf");
if (_font == null)
_font = Resources.GetBuiltinResource<Font>("Builtin-LegacyRuntime.ttf");
return _font;
}
}
public static TextMesh Create(
Transform parent,
string text,
float characterSize,
Color color,
Vector3 localPosition,
float yaw = 0f,
TextAnchor anchor = TextAnchor.MiddleCenter)
{
var go = new GameObject("Board Label");
go.transform.SetParent(parent, false);
go.transform.localPosition = localPosition;
// TextMesh по умолчанию смотрит в -Z; +90° по X кладёт его на стол.
go.transform.localRotation = Quaternion.Euler(90f, yaw, 0f);
var mesh = go.AddComponent<TextMesh>();
mesh.font = Font;
mesh.fontSize = 64;
mesh.characterSize = characterSize;
mesh.anchor = anchor;
mesh.alignment = TextAlignment.Center;
mesh.color = color;
mesh.text = text;
mesh.richText = false;
var renderer = go.GetComponent<MeshRenderer>();
if (mesh.font != null && mesh.font.material != null)
renderer.sharedMaterial = mesh.font.material;
renderer.shadowCastingMode = ShadowCastingMode.Off;
renderer.receiveShadows = false;
renderer.sortingOrder = 1;
return mesh;
}
public static void SetText(TextMesh mesh, string text)
{
if (mesh != null)
mesh.text = text ?? string.Empty;
}
public static void SetColor(TextMesh mesh, Color color)
{
if (mesh != null)
mesh.color = color;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5622bffba113d9843b24961dd5dbf81f

View File

@ -0,0 +1,112 @@
using UnityEngine;
namespace Vanguard.Board3D
{
/// <summary>Процедурная рубашка и запасное лицо карты без внешних ассетов.</summary>
internal static class BoardTextures
{
private const int BackWidth = 256;
private const int BackHeight = 356;
private static Texture2D _back;
private static Texture2D _white;
public static Texture2D White
{
get
{
if (_white == null)
{
_white = new Texture2D(2, 2, TextureFormat.RGBA32, false)
{
name = "Board White",
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp,
hideFlags = HideFlags.DontSave,
};
_white.SetPixels(new[] { Color.white, Color.white, Color.white, Color.white });
_white.Apply(false, true);
}
return _white;
}
}
public static Texture2D Back
{
get
{
if (_back == null)
_back = BuildBack();
return _back;
}
}
public static Color AccentFor(string printingId)
{
var hash = 2166136261u;
if (!string.IsNullOrEmpty(printingId))
{
foreach (var ch in printingId)
{
hash ^= ch;
hash *= 16777619u;
}
}
var hue = (hash % 360u) / 360f;
return Color.HSVToRGB(hue, 0.42f, 0.62f);
}
private static Texture2D BuildBack()
{
var texture = new Texture2D(
BackWidth,
BackHeight,
TextureFormat.RGBA32,
false)
{
name = "Board Card Back",
filterMode = FilterMode.Bilinear,
wrapMode = TextureWrapMode.Clamp,
hideFlags = HideFlags.DontSave,
};
var pixels = new Color[BackWidth * BackHeight];
var dark = new Color(0.035f, 0.055f, 0.11f);
var mid = new Color(0.10f, 0.18f, 0.36f);
var edge = new Color(0.72f, 0.56f, 0.22f);
for (var y = 0; y < BackHeight; y++)
{
for (var x = 0; x < BackWidth; x++)
{
var u = (x + 0.5f) / BackWidth;
var v = (y + 0.5f) / BackHeight;
var border = Mathf.Min(
Mathf.Min(u, 1f - u) * BackWidth,
Mathf.Min(v, 1f - v) * BackHeight);
var color = Color.Lerp(dark, mid, v * 0.55f + u * 0.15f);
var diagonal = Mathf.Abs(((u + v) % 0.5f) - 0.25f);
if (diagonal < 0.018f)
color = Color.Lerp(color, mid, 0.45f);
var dx = Mathf.Abs(u - 0.5f) * BackWidth;
var dy = Mathf.Abs(v - 0.5f) * BackHeight;
if (dx + dy < 34f)
color = Color.Lerp(color, edge, 0.65f);
if (border < 4f)
color = edge;
pixels[(y * BackWidth) + x] = color;
}
}
texture.SetPixels(pixels);
texture.Apply(false, true);
return texture;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 41706474af643f54c81c3469dea25f40

View File

@ -0,0 +1,89 @@
using UnityEngine;
namespace Vanguard.Board3D
{
/// <summary>Плоская зона стола: общий круг защиты, урон, душа.</summary>
public sealed class BoardZoneView : MonoBehaviour
{
private MeshRenderer _renderer;
private MaterialPropertyBlock _block;
private Color _baseColor;
private Color _highlightColor;
private TextMesh _label;
public static BoardZoneView Create(
Transform parent,
string name,
Vector3 position,
float width,
float depth,
float border,
Color color,
string label,
float labelSize,
float labelOffsetZ,
Color labelColor,
Color highlightColor)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
go.transform.localPosition = position;
var view = go.AddComponent<BoardZoneView>();
view._baseColor = color;
view._highlightColor = highlightColor;
view._block = new MaterialPropertyBlock();
var filter = go.AddComponent<MeshFilter>();
filter.sharedMesh = BoardMeshLibrary.Frame(width, depth, border);
var renderer = go.AddComponent<MeshRenderer>();
renderer.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(renderer, false);
view._renderer = renderer;
view.ApplyColor(false);
if (!string.IsNullOrEmpty(label))
{
view._label = BoardText.Create(
go.transform,
label,
labelSize,
labelColor,
new Vector3(0f, 0.002f, labelOffsetZ));
}
return view;
}
public void SetLabel(string text)
{
BoardText.SetText(_label, text);
}
public void SetHighlighted(bool highlighted)
{
ApplyColor(highlighted);
}
public void SetColor(Color color)
{
_baseColor = color;
ApplyColor(false);
}
private void ApplyColor(bool highlighted)
{
if (_renderer == null)
return;
_renderer.GetPropertyBlock(_block);
_block.Clear();
BoardMaterials.SetColor(
_block,
_renderer.sharedMaterial,
highlighted ? _highlightColor : _baseColor);
_renderer.SetPropertyBlock(_block);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b56ad9ea684c45d418241b20af51c21a

View File

@ -0,0 +1,274 @@
using UnityEngine;
using UnityEngine.Rendering;
namespace Vanguard.Board3D
{
/// <summary>
/// Карта на столе: тонкая коробка с отдельными материалами лица, рубашки
/// и торца. Rest — поворот вокруг Y, закрытость — переворот вокруг X.
/// </summary>
public sealed class Card3D : MonoBehaviour
{
private PlaymatLayoutConfig _config;
private MeshRenderer _front;
private MeshRenderer _back;
private MeshRenderer _edge;
private MeshRenderer _highlight;
private BoxCollider _collider;
private MaterialPropertyBlock _block;
private bool _faceUp = true;
private bool _rested;
private Quaternion _baseRotation = Quaternion.identity;
private Quaternion _targetRotation = Quaternion.identity;
private Vector3 _targetPosition;
private Vector3 _targetScale = Vector3.one;
private bool _immediate;
private bool _dragging;
private Vector3 _dragOriginPosition;
private Quaternion _dragOriginRotation;
public BoardCardRef Ref { get; private set; }
public bool IsFaceUp => _faceUp;
public bool IsRested => _rested;
public bool IsDragging => _dragging;
public Vector3 TargetPosition => _targetPosition;
public float TargetYaw => _targetRotation.eulerAngles.y;
public Quaternion TargetRotation => _targetRotation;
public static Card3D Create(Transform parent, PlaymatLayoutConfig config)
{
var go = new GameObject("Card3D");
go.transform.SetParent(parent, false);
var card = go.AddComponent<Card3D>();
card.Build(config != null ? config : PlaymatLayoutConfig.Default);
return card;
}
private void Build(PlaymatLayoutConfig config)
{
_config = config;
_block = new MaterialPropertyBlock();
var width = _config.cardWidth;
var height = _config.cardHeight;
var thickness = _config.cardThickness;
var edgeGo = GameObject.CreatePrimitive(PrimitiveType.Cube);
edgeGo.name = "Edge";
RemoveCollider(edgeGo);
edgeGo.transform.SetParent(transform, false);
edgeGo.transform.localScale = new Vector3(width, thickness, height);
_edge = edgeGo.GetComponent<MeshRenderer>();
_edge.sharedMaterial = BoardMaterials.Edge;
BoardMaterials.SetShadows(_edge, true);
var frontGo = new GameObject("Front");
frontGo.transform.SetParent(transform, false);
frontGo.transform.localPosition = new Vector3(0f, (thickness * 0.5f) + 0.0008f, 0f);
var frontFilter = frontGo.AddComponent<MeshFilter>();
frontFilter.sharedMesh = BoardMeshLibrary.CardFace(width, height, front: true);
_front = frontGo.AddComponent<MeshRenderer>();
_front.sharedMaterial = BoardMaterials.White;
BoardMaterials.SetShadows(_front, false);
var backGo = new GameObject("Back");
backGo.transform.SetParent(transform, false);
backGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0008f, 0f);
var backFilter = backGo.AddComponent<MeshFilter>();
backFilter.sharedMesh = BoardMeshLibrary.CardFace(width, height, front: false);
_back = backGo.AddComponent<MeshRenderer>();
_back.sharedMaterial = BoardMaterials.Back;
BoardMaterials.SetShadows(_back, false);
var highlightGo = new GameObject("Highlight");
highlightGo.transform.SetParent(transform, false);
highlightGo.transform.localPosition = new Vector3(0f, -(thickness * 0.5f) - 0.0015f, 0f);
var filter = highlightGo.AddComponent<MeshFilter>();
filter.sharedMesh = BoardMeshLibrary.Frame(
width * 1.16f,
height * 1.16f,
Mathf.Max(0.025f, width * 0.035f));
_highlight = highlightGo.AddComponent<MeshRenderer>();
_highlight.sharedMaterial = BoardMaterials.Highlight;
BoardMaterials.SetShadows(_highlight, false);
_highlight.gameObject.SetActive(false);
_collider = gameObject.AddComponent<BoxCollider>();
_collider.size = new Vector3(
width * 1.02f,
Mathf.Max(thickness + 0.02f, 0.03f),
height * 1.02f);
}
public void Bind(BoardCardRef reference)
{
Ref = reference;
name = "Card3D " + reference;
}
public void SetArt(Texture2D texture, Color accent)
{
if (_front == null)
return;
_front.GetPropertyBlock(_block);
_block.Clear();
BoardMaterials.SetTexture(
_block,
_front.sharedMaterial,
texture != null ? texture : BoardTextures.White);
BoardMaterials.SetColor(
_block,
_front.sharedMaterial,
texture != null ? Color.white : accent);
_front.SetPropertyBlock(_block);
}
public void SetState(Quaternion baseRotation, bool faceUp, bool rested)
{
_baseRotation = baseRotation;
_faceUp = faceUp;
_rested = rested;
var rest = Quaternion.Euler(0f, rested ? 90f : 0f, 0f);
var flip = Quaternion.Euler(faceUp ? 0f : 180f, 0f, 0f);
_targetRotation = rest * baseRotation * flip;
}
public void SetPose(
Vector3 position,
Quaternion baseRotation,
bool faceUp,
bool rested,
bool immediate)
{
SetState(baseRotation, faceUp, rested);
_targetPosition = position;
_targetScale = Vector3.one;
_immediate = immediate;
if (_dragging)
return;
if (immediate)
Snap();
}
public void SetVisible(bool visible)
{
gameObject.SetActive(visible);
}
public void SetHighlight(Color? color)
{
if (_highlight == null)
return;
var active = color.HasValue;
_highlight.gameObject.SetActive(active);
if (!active)
return;
_highlight.GetPropertyBlock(_block);
_block.Clear();
BoardMaterials.SetColor(_block, _highlight.sharedMaterial, color.Value);
_highlight.SetPropertyBlock(_block);
}
public void BeginDrag()
{
if (_dragging)
return;
_dragging = true;
_dragOriginPosition = transform.position;
_dragOriginRotation = transform.rotation;
_targetScale = Vector3.one * 1.05f;
if (_collider != null)
_collider.enabled = false;
SetHighlight(null);
}
public void DragTo(Vector3 worldPosition)
{
if (!_dragging)
return;
var yaw = _targetRotation.eulerAngles.y;
transform.position = new Vector3(
worldPosition.x,
_config.dragLift + (_config.cardThickness * 0.5f),
worldPosition.z);
transform.rotation = Quaternion.Euler(0f, yaw, 0f);
transform.localScale = Vector3.one * 1.05f;
}
public void DropAt(Vector3 position, Quaternion rotation)
{
_dragging = false;
if (_collider != null)
_collider.enabled = true;
_targetPosition = position;
_targetRotation = rotation;
_targetScale = Vector3.one;
_immediate = false;
}
public void ReturnToOrigin()
{
_dragging = false;
if (_collider != null)
_collider.enabled = true;
_targetPosition = _dragOriginPosition;
_targetRotation = _dragOriginRotation;
_targetScale = Vector3.one;
_immediate = false;
}
public void Snap()
{
transform.position = _targetPosition;
transform.rotation = _targetRotation;
transform.localScale = _targetScale;
_immediate = false;
}
public void Tick(float deltaTime)
{
if (_dragging || deltaTime <= 0f)
return;
if (_immediate)
{
Snap();
return;
}
var move = 1f - Mathf.Exp(-_config.smoothMoveSpeed * deltaTime);
var rotate = 1f - Mathf.Exp(-_config.smoothRotateSpeed * deltaTime);
transform.position = Vector3.Lerp(transform.position, _targetPosition, move);
transform.rotation = Quaternion.Slerp(transform.rotation, _targetRotation, rotate);
transform.localScale = Vector3.Lerp(transform.localScale, _targetScale, move);
if (Vector3.SqrMagnitude(transform.position - _targetPosition) < 0.0000004f
&& Quaternion.Angle(transform.rotation, _targetRotation) < 0.1f)
{
Snap();
}
}
private static void RemoveCollider(GameObject target)
{
var collider = target.GetComponent<Collider>();
if (collider == null)
return;
if (Application.isPlaying)
Destroy(collider);
else
DestroyImmediate(collider);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a8b5670bd4e091143b3d4752c0bb2b1a

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 178189a769df10042b9a76f8a2b7bb84

View File

@ -0,0 +1,146 @@
using UnityEngine;
namespace Vanguard.Board3D
{
/// <summary>Колода, дроп или душа: стопка карт с подписью и коллайдером.</summary>
public sealed class Pile3D : MonoBehaviour
{
private PlaymatLayoutConfig _config;
private readonly GameObject[] _layers = new GameObject[3];
private MeshRenderer _top;
private MaterialPropertyBlock _block;
private TextMesh _label;
private int _count;
public BoardZone Zone { get; private set; }
public int Player { get; private set; }
public BoardCardRef Ref => BoardCardRef.Pile(Zone, Player, _count);
public void SetPlayer(int player)
{
Player = player;
}
public static Pile3D Create(
Transform parent,
PlaymatLayoutConfig config,
BoardZone zone,
int player,
Vector3 position,
string title,
Color accent)
{
var go = new GameObject("Pile " + zone);
go.transform.SetParent(parent, false);
go.transform.localPosition = position;
var pile = go.AddComponent<Pile3D>();
pile.Zone = zone;
pile.Player = player;
pile._config = config;
pile._block = new MaterialPropertyBlock();
pile.Build(title, accent, config);
return pile;
}
private void Build(string title, Color accent, PlaymatLayoutConfig config)
{
var width = config.cardWidth;
var height = config.cardHeight;
var thickness = Mathf.Max(0.006f, config.cardThickness * 1.2f);
for (var i = 0; i < _layers.Length; i++)
{
var layer = GameObject.CreatePrimitive(PrimitiveType.Cube);
layer.name = "Layer " + i;
RemoveCollider(layer);
layer.transform.SetParent(transform, false);
layer.transform.localPosition = new Vector3(0f, i * thickness, 0f);
layer.transform.localScale = new Vector3(width, thickness, height);
var renderer = layer.GetComponent<MeshRenderer>();
renderer.sharedMaterial = Zone == BoardZone.Soul
? BoardMaterials.CreateUnlit(null, accent)
: BoardMaterials.Back;
BoardMaterials.SetShadows(renderer, true);
_layers[i] = layer;
}
var topGo = new GameObject("Top");
topGo.transform.SetParent(transform, false);
var topFilter = topGo.AddComponent<MeshFilter>();
topFilter.sharedMesh = BoardMeshLibrary.CardFace(width, height, front: true);
_top = topGo.AddComponent<MeshRenderer>();
_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)));
var collider = gameObject.AddComponent<BoxCollider>();
collider.size = new Vector3(width * 1.06f, 0.16f, height * 1.06f);
collider.center = new Vector3(0f, 0.08f, 0f);
}
public void SetCount(int count, Texture2D topTexture, string title, Color accent)
{
_count = count;
var safe = Mathf.Max(0, count);
var layers = safe <= 0 ? 1 : safe < 5 ? 1 : safe < 20 ? 2 : 3;
if (Zone == BoardZone.Soul)
layers = safe < 10 ? 1 : 2;
var thickness = Mathf.Max(0.006f, _config.cardThickness * 1.2f);
for (var i = 0; i < _layers.Length; i++)
_layers[i].SetActive(i < layers);
_top.transform.localPosition = new Vector3(
0f,
(layers - 1) * thickness + 0.0015f,
0f);
if (topTexture != null)
{
_top.GetPropertyBlock(_block);
_block.Clear();
BoardMaterials.SetTexture(_block, _top.sharedMaterial, topTexture);
BoardMaterials.SetColor(_block, _top.sharedMaterial, Color.white);
_top.SetPropertyBlock(_block);
}
else
{
_top.GetPropertyBlock(_block);
_block.Clear();
if (Zone == BoardZone.Soul)
{
BoardMaterials.SetTexture(_block, _top.sharedMaterial, BoardTextures.White);
BoardMaterials.SetColor(_block, _top.sharedMaterial, accent);
}
else
{
BoardMaterials.SetTexture(_block, _top.sharedMaterial, BoardTextures.Back);
BoardMaterials.SetColor(_block, _top.sharedMaterial, Color.white);
}
_top.SetPropertyBlock(_block);
}
BoardText.SetText(_label, title + " " + safe);
}
private static void RemoveCollider(GameObject target)
{
var collider = target.GetComponent<Collider>();
if (collider == null)
return;
if (Application.isPlaying)
Destroy(collider);
else
DestroyImmediate(collider);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2cbf86187ded1564ea87365f921f8be5

File diff suppressed because it is too large Load Diff

View File

@ -21,25 +21,26 @@ namespace Vanguard.UI
if (target == null)
return;
target.sprite = fallback;
var sprite = Resolve(imageKey, fallback);
target.sprite = sprite;
target.color = Color.white;
target.preserveAspect = false;
}
/// <summary>Спрайт реального арта или процедурный fallback.</summary>
public static Sprite Resolve(string imageKey, Sprite fallback)
{
if (string.IsNullOrWhiteSpace(imageKey))
return;
return fallback;
if (Cache.TryGetValue(imageKey, out var cached))
{
if (cached != null)
target.sprite = cached;
return;
}
return cached != null ? cached : fallback;
var texture = Resources.Load<Texture2D>(ResourceFolder + imageKey);
if (texture == null)
{
Cache[imageKey] = null;
return;
return fallback;
}
var sprite = Sprite.Create(
@ -48,7 +49,7 @@ namespace Vanguard.UI
new Vector2(0.5f, 0.5f),
100f);
Cache[imageKey] = sprite;
target.sprite = sprite;
return sprite;
}
}
}

View File

@ -0,0 +1,354 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 0
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 2
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 2
m_PVRDenoiserTypeIndirect: 2
m_PVRDenoiserTypeAO: 2
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 1
m_PVRFilteringGaussRadiusAO: 1
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &101028085
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 101028087}
- component: {fileID: 101028086}
m_Layer: 0
m_Name: Directional Light
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!108 &101028086
Light:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 101028085}
m_Enabled: 1
serializedVersion: 13
m_Type: 1
m_Color: {r: 1, g: 0.88, b: 0.68, a: 1}
m_Intensity: 1.15
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize2D: {x: 0.5, y: 0.5}
m_Shadows:
m_Type: 2
m_Resolution: -1
m_CustomResolution: -1
m_Strength: 1
m_Bias: 0.05
m_NormalBias: 0.4
m_NearPlane: 0.2
m_CullingMatrixOverride:
e00: 1
e01: 0
e02: 0
e03: 0
e10: 0
e11: 1
e12: 0
e13: 0
e20: 0
e21: 0
e22: 1
e23: 0
e30: 0
e31: 0
e32: 0
e33: 1
m_UseCullingMatrixOverride: 0
m_Cookie: {fileID: 0}
m_DrawHalo: 0
m_Flare: {fileID: 0}
m_RenderMode: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingLayerMask: 1
m_Lightmapping: 4
m_LightShadowCasterMode: 0
m_AreaSize: {x: 1, y: 1}
m_BounceIntensity: 1
m_ColorTemperature: 6570
m_UseColorTemperature: 0
m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
m_UseBoundingSphereOverride: 0
m_UseViewFrustumForShadowCasterCull: 1
m_ForceVisible: 0
m_ShapeRadius: 0.025
m_ShadowAngle: 0
m_LightUnit: 1
m_LuxAtDistance: 1
m_EnableSpotReflector: 1
--- !u!4 &101028087
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 101028085}
serializedVersion: 2
m_LocalRotation: {x: 0.39098036, y: -0.25180724, z: 0.112111814, w: 0.87815624}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &320665827
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 320665829}
- component: {fileID: 320665828}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!20 &320665828
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 320665827}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 2
m_BackGroundColor: {r: 0.02, g: 0.025, b: 0.045, a: 1}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 1000
field of view: 50
orthographic: 0
orthographic size: 5
m_Depth: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &320665829
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 320665827}
serializedVersion: 2
m_LocalRotation: {x: 0.29581374, y: 0, z: 0, w: 0.9552457}
m_LocalPosition: {x: 0, y: 8.7, z: -12.4}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1168794765
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1168794767}
- component: {fileID: 1168794766}
m_Layer: 0
m_Name: Playmat Sandbox
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1168794766
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1168794765}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 89fedfc3c1f371c448b4187e210d36fb, type: 3}
m_Name:
m_EditorClassIdentifier: Vanguard.Runtime::Vanguard.Board3D.PlaymatSandbox
config: {fileID: 11400000, guid: c1f4dc13d22d0004ba1bddedecf71461, type: 2}
showHints: 1
--- !u!4 &1168794767
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1168794765}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 320665829}
- {fileID: 101028087}
- {fileID: 1168794767}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3282ed3425597474dbc400ae4316780f
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -9,6 +9,7 @@ using Vanguard.Api;
using Vanguard.Api.Contracts;
using Vanguard.App;
using Vanguard.Auth;
using Vanguard.Board3D;
using Vanguard.Meta;
using Vanguard.Shared.Protocol;
using Vanguard.State;
@ -120,7 +121,7 @@ namespace Vanguard.Tests
}
[Test]
public void Battle_Renders_Damage_And_Rested_Cards()
public void Battle_Renders_3D_Damage_And_Rested_Cards()
{
var config = new AppConfig();
config.Load();
@ -192,6 +193,10 @@ namespace Vanguard.Tests
LegalIntents = new List<IntentPayload>(),
};
Resources.FindObjectsOfTypeAll<GameBoard3D>()
.FirstOrDefault()
?.SetVisible(true);
typeof(BattleView)
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(battle, match);
@ -199,21 +204,30 @@ namespace Vanguard.Tests
.GetMethod("Render", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(battle, null);
Assert.IsTrue(Exists("DamageZone"));
Assert.IsTrue(Exists("Damage_0"));
Assert.IsTrue(Exists("Damage_1"));
Assert.IsTrue(Exists("Damage_2"));
Assert.IsTrue(Exists("Guard_7"));
Assert.IsTrue(Exists("Card_R4"));
var hasSidewaysCard = Resources.FindObjectsOfTypeAll<GameObject>()
.Where(go => go.name == "Card_R4")
.Any(go =>
{
var angle = go.transform.localEulerAngles.z;
return Mathf.Abs(Mathf.DeltaAngle(angle, 90f)) < 1f
|| Mathf.Abs(Mathf.DeltaAngle(angle, -90f)) < 1f;
});
Assert.IsTrue(hasSidewaysCard, "Rested card was not turned sideways");
Assert.IsTrue(Exists("GameBoard3D"));
var cards = Resources.FindObjectsOfTypeAll<Card3D>()
.Where(card => card.gameObject.activeInHierarchy)
.ToList();
Assert.GreaterOrEqual(cards.Count, 3);
var rested = cards.FirstOrDefault(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& card.Ref.Slot == 3);
Assert.IsNotNull(rested, "Rested rear-guard was not rendered in 3D");
Assert.IsTrue(rested.IsRested);
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f);
var damage = cards.Where(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0).ToList();
Assert.AreEqual(3, damage.Count);
Assert.IsFalse(damage.Single(card => card.Ref.Index == 2).IsFaceUp);
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Guardian
&& card.Ref.InstanceId == 7));
Cleanup();
}
@ -223,6 +237,12 @@ namespace Vanguard.Tests
private static void Cleanup()
{
foreach (var board in Resources.FindObjectsOfTypeAll<GameBoard3D>())
{
if (board != null)
Object.DestroyImmediate(board.gameObject);
}
var canvas = GameObject.Find("Test Canvas");
if (canvas != null)
Object.DestroyImmediate(canvas);

View File

@ -0,0 +1,206 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using Vanguard.Board3D;
using Vanguard.Shared.Protocol;
namespace Vanguard.Tests
{
public sealed class GameBoard3DTests
{
[Test]
public void Board_Renders_Field_Hand_Guardian_Damage_And_Soul()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 0), new BoardRenderState());
var cards = ActiveCards();
Assert.GreaterOrEqual(cards.Count, 9);
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& card.Ref.Slot == 3));
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Guardian
&& card.Ref.InstanceId == 7));
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Hand
&& card.Ref.Player == 0));
Assert.IsTrue(cards.Any(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0));
var rested = cards.Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& card.Ref.Slot == 3);
Assert.IsTrue(rested.IsRested);
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f);
var hiddenDamage = cards.Single(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0
&& card.Ref.Index == 1);
Assert.IsFalse(hiddenDamage.IsFaceUp);
Assert.IsTrue(hiddenDamage.IsRested);
var visibleDamage = cards.Single(card =>
card.Ref.Zone == BoardZone.Damage
&& card.Ref.Player == 0
&& card.Ref.Index == 0);
Assert.IsTrue(visibleDamage.IsFaceUp);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Pool_Reuses_Cards_Between_Snapshots()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
board.Render(view, new BoardRenderState());
var count = ActiveCards().Count;
board.Render(view, new BoardRenderState());
Assert.AreEqual(count, ActiveCards().Count);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Local_Seat_Is_Always_Bottom_Of_The_Board()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
board.Render(BuildView(you: 1), new BoardRenderState());
var vanguard = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Vanguard
&& card.Ref.Player == 1);
Assert.Less(vanguard.TargetPosition.z, 0f);
var opponent = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.Vanguard
&& card.Ref.Player == 0);
Assert.Greater(opponent.TargetPosition.z, 0f);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test]
public void Highlight_And_Selection_State_Is_Applied_To_Cards()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var state = new BoardRenderState();
state.Highlights.Add(new BoardSlotKey(0, 3));
state.SelectedHandInstanceIds.Add(12);
board.Render(BuildView(you: 0), state);
var rested = ActiveCards().Single(card =>
card.Ref.Zone == BoardZone.RearGuard
&& card.Ref.Player == 0
&& card.Ref.Slot == 3);
Assert.IsTrue(rested.transform.Find("Highlight").gameObject.activeSelf);
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
private static List<Card3D> ActiveCards() =>
Resources.FindObjectsOfTypeAll<Card3D>()
.Where(card => card.gameObject.activeInHierarchy)
.ToList();
private static MatchView BuildView(int you)
{
CardView Card(string id, int instance, bool rested = false, bool faceUp = true) =>
new CardView
{
PrintingId = id,
InstanceId = instance,
Rested = rested,
FaceUp = faceUp,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
};
var self = new PlayerView
{
Index = you,
Name = "Self",
Vanguard = Card("BT01/001EN", 1),
RearGuards = new CardView[5],
Hand = new List<CardView>
{
Card("BT01/002EN", 11),
Card("BT01/003EN", 12),
},
Guardian = new List<CardView> { Card("BT01/004EN", 7) },
Soul = new List<CardView> { Card("BT01/005EN", 8) },
FaceUpDamage = new List<CardView> { Card("BT01/006EN", 9) },
FaceDownDamage = 2,
DeckCount = 40,
Drop = new List<CardView>(),
};
self.RearGuards[3] = Card("BT01/007EN", 5, rested: true);
var otherIndex = 1 - you;
var opponent = new PlayerView
{
Index = otherIndex,
Name = "Opponent",
Vanguard = Card("BT01/010EN", 21),
RearGuards = new CardView[5],
Hand = new List<CardView>(),
Guardian = new List<CardView>(),
Soul = new List<CardView>(),
FaceUpDamage = new List<CardView>(),
FaceDownDamage = 0,
DeckCount = 40,
Drop = new List<CardView>(),
};
opponent.RearGuards[4] = Card("BT01/011EN", 22, rested: true);
return new MatchView
{
MatchId = "board-test",
You = you,
ActivePlayer = you,
Phase = GamePhase.Main,
Players = new List<PlayerView> { self, opponent },
Clock = new ClockView(),
LegalIntents = new List<IntentPayload>(),
};
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9caafc3153efd3241b024681536dc78a

View File

@ -0,0 +1,164 @@
using System.Linq;
using NUnit.Framework;
using UnityEngine;
using Vanguard.Board3D;
namespace Vanguard.Tests
{
public sealed class PlaymatLayoutTests
{
[Test]
public void Default_Playmat_Has_Six_Circles_Per_Side()
{
var config = PlaymatLayoutConfig.CreateDefault();
var layout = new PlaymatLayout(config);
for (var seat = 0; seat < 2; seat++)
{
var positions = Enumerable.Range(-1, PlaymatLayout.RearGuardCount + 1)
.Select(slot => layout.CirclePosition(seat, slot))
.ToList();
Assert.AreEqual(6, positions.Count);
Assert.AreEqual(6, positions.Select(position => position.ToString()).Distinct().Count());
}
}
[Test]
public void Vanguard_Is_Center_Front_And_Rear_Center_Exists()
{
var config = PlaymatLayoutConfig.CreateDefault();
var layout = new PlaymatLayout(config);
var vanguard = layout.CirclePosition(0, PlaymatLayout.VanguardSlot);
var rearCenter = layout.CirclePosition(0, 1);
var leftFront = layout.CirclePosition(0, 3);
var rightFront = layout.CirclePosition(0, 4);
Assert.AreEqual(0f, vanguard.x, 0.0001f);
Assert.AreEqual(-config.frontRowZ, vanguard.z, 0.0001f);
Assert.AreEqual(0f, rearCenter.x, 0.0001f);
Assert.AreEqual(-config.backRowZ, rearCenter.z, 0.0001f);
Assert.Less(leftFront.x, 0f);
Assert.Greater(rightFront.x, 0f);
Assert.AreEqual(vanguard.z, leftFront.z, 0.0001f);
Assert.AreEqual(vanguard.z, rightFront.z, 0.0001f);
}
[Test]
public void Opponent_Half_Is_Mirrored()
{
var config = PlaymatLayoutConfig.CreateDefault();
var layout = new PlaymatLayout(config);
for (var slot = PlaymatLayout.VanguardSlot; slot < PlaymatLayout.RearGuardCount; slot++)
{
var self = layout.CirclePosition(0, slot);
var opponent = layout.CirclePosition(1, slot);
Assert.AreEqual(-self.x, opponent.x, 0.0001f);
Assert.AreEqual(-self.z, opponent.z, 0.0001f);
Assert.AreEqual(self.y, opponent.y, 0.0001f);
}
}
[Test]
public void Damage_And_Hand_Do_Not_Overlap_Field()
{
var config = PlaymatLayoutConfig.CreateDefault();
var layout = new PlaymatLayout(config);
var damage = Enumerable.Range(0, PlaymatLayout.DamageSlots)
.Select(index => layout.DamagePosition(0, index))
.ToList();
Assert.AreEqual(PlaymatLayout.DamageSlots,
damage.Select(position => position.ToString()).Distinct().Count());
var hand = Enumerable.Range(0, 5)
.Select(index => layout.GetHandPose(index, 5).Position)
.ToList();
Assert.AreEqual(5, hand.Select(position => position.ToString()).Distinct().Count());
Assert.IsTrue(hand.All(position => position.z < -4f));
}
[Test]
public void Drop_Target_Finds_Own_And_Opponent_Circles()
{
var config = PlaymatLayoutConfig.CreateDefault();
var layout = new PlaymatLayout(config);
var nearPoint = layout.CirclePosition(0, 3);
Assert.IsTrue(layout.TryFindDropTarget(nearPoint, selfPlayer: 1, out var near));
Assert.AreEqual(BoardZone.RearGuard, near.Zone);
Assert.AreEqual(3, near.Slot);
Assert.AreEqual(1, near.Player);
var farPoint = layout.CirclePosition(1, -1);
Assert.IsTrue(layout.TryFindDropTarget(farPoint, selfPlayer: 1, out var far));
Assert.AreEqual(BoardZone.Vanguard, far.Zone);
Assert.AreEqual(-1, far.Slot);
Assert.AreEqual(0, far.Player);
}
[Test]
public void Drop_Target_Recognizes_Shared_Guardian_Zone()
{
var config = PlaymatLayoutConfig.CreateDefault();
var layout = new PlaymatLayout(config);
Assert.IsTrue(layout.TryFindDropTarget(
new Vector3(0f, 0f, -0.45f),
selfPlayer: 0,
out var target));
Assert.AreEqual(BoardZone.Guardian, target.Zone);
Assert.AreEqual(0, target.Player);
Assert.IsFalse(layout.TryFindDropTarget(
new Vector3(0f, 0f, -7f),
selfPlayer: 0,
out _));
}
[Test]
public void Move_Is_Limited_To_Own_Column()
{
Assert.IsTrue(BoardPlacementRules.CanMove(0, 3));
Assert.IsTrue(BoardPlacementRules.CanMove(3, 0));
Assert.IsTrue(BoardPlacementRules.CanMove(2, 4));
Assert.IsTrue(BoardPlacementRules.CanMove(4, 2));
Assert.IsFalse(BoardPlacementRules.CanMove(1, 0));
Assert.IsFalse(BoardPlacementRules.CanMove(0, 1));
Assert.IsFalse(BoardPlacementRules.CanMove(3, 4));
Assert.IsFalse(BoardPlacementRules.CanMove(0, 0));
}
[Test]
public void Call_Ride_And_Guard_Follow_Classic_Circles()
{
Assert.IsTrue(BoardPlacementRules.CanCall(0, occupied: false));
Assert.IsTrue(BoardPlacementRules.CanCall(4, occupied: false));
Assert.IsFalse(BoardPlacementRules.CanCall(0, occupied: true));
Assert.IsFalse(BoardPlacementRules.CanCall(5, occupied: false));
Assert.IsTrue(BoardPlacementRules.CanRide(BoardZone.Vanguard, -1));
Assert.IsFalse(BoardPlacementRules.CanRide(BoardZone.RearGuard, 0));
Assert.IsTrue(BoardPlacementRules.CanGuard(BoardZone.Guardian));
Assert.IsFalse(BoardPlacementRules.CanGuard(BoardZone.Damage));
}
[Test]
public void Call_Ride_Enter_Stand_And_Move_Preserves_State()
{
Assert.AreEqual(
CardState3D.FaceUpStand,
BoardPlacementRules.EnterFromHand(CircleAttribute.Vanguard));
Assert.AreEqual(
CardState3D.FaceUpStand,
BoardPlacementRules.EnterFromHand(CircleAttribute.RearGuard));
var rested = CardState3D.FaceUpRest;
Assert.AreEqual(rested, BoardPlacementRules.PreserveOnMove(rested));
Assert.IsTrue(BoardPlacementRules.PreserveOnMove(rested).Rested);
Assert.IsTrue(BoardPlacementRules.PreserveOnMove(rested).FaceUp);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 961f459713ed4e7469a79379d1d9afaf

View File

@ -832,3 +832,36 @@ Battle; Boot и общие сервисы остаются персистент
клиенту открытыми; в круге защиты дополнительно рисуется ряд карт.
- Classic-режим не включает Stride/Вознесение, Legacy, Gift, Front-триггер
и Grade support.
## 2026-10-22 — 3D Playmat
- Боевое поле переведено в 3D: `Runtime/Board3D` (`Config`, `Data`,
`Logic`, `View`), HUD/кнопки остаются uGUI.
- Раскладка: локальный игрок всегда внизу; передний ряд `RC–V–RC`, задний
`RC–RC–RC`; у противника зеркально. Между половинами — общий круг защиты
(карты обоих игроков и текущий Guard). Душа — стопка под V, колода и дроп
сбоку, урон — две колонки по три карты на боку.
- `PlaymatLayoutConfig` (ScriptableObject): размеры карт, позиции рядов,
высоты, рука, камера, палитра. Ассет:
`Runtime/Board3D/Config/PlaymatLayoutConfig.asset`.
- `PlaymatLayout` — чистая геометрия и поиск цели броска; `BoardPlacementRules`
— Ride только на V, Call только на пустой RC, Move только в своей колонке
(0↔3, 2↔4, занятый круг меняется местами), Guard — в общий круг.
- `Card3D` — тонкая коробка: отдельные лицо/рубашка/торец, коллайдер, арт из
`CardImageService`, переворот вокруг X, Rest вокруг Y, плавный снап.
Карты пулятся (`GameBoard3D`), материалы общие, instancing включён.
- `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики,
drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и
индикатором точки броска. Ошибка интента возвращает карту на место.
- Камера: `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`.
- Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены;
модели карт/стола — процедурные примитивы.

View File

@ -61,13 +61,16 @@
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
создаёт casual-матч против случайного бота (`POST /api/game/practice`),
без истории, MMR и наград.
- Боевой экран: реальный арт на всю карту, рамка по клану, имя, ранг,
триггер, сила/крит и рубашка; fallback — процедурная плашка.
Rest поворачивает карту на бок. Карты в кругах V/R и в круге защиты
показываются открытыми.
- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
(+soul), справа колода и ниже дроп; у противника зеркально; между
полями — зона защиты со щитом и проверками.
- Боевой экран: поле, рука и стопки — **3D** (`GameBoard3D`), HUD/кнопки
остаются uGUI. Карты — тонкие 3D-объекты с артом, рубашкой, торцом и
коллайдером. Rest — поворот вокруг Y, закрытость — переворот вокруг X.
Драг-н-дроп на плоскость стола с подсветкой целей и плавным снапом.
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
противника зеркально; между полями общий круг защиты; душа — стопка под
V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу,
даже если он за вторым местом. ПКМ — орбита, СКМ — панорама, колесо —
зум. Кнопка «Душа: n» в верхней панели и клик по стопке души открывают
список карт души.
- Зона урона: карты лежат открытыми и на боку; после Counter Blast
выбранная карта переворачивается рубашкой. Heal выбирает карту урона;
Counter Blast — открытую карту до активации способности.
@ -75,8 +78,8 @@
колоды, добираются новые, затем shuffle. Клик по руке выбирает карты.
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют,
ушедшие в дроп карты летят призраком и растворяются.
3D-карты плавно снапятся, поворачиваются в Rest и переворачиваются;
подсветка целей и точки броска обновляются на каждом снимке.
- Реальные арты: `imageKey` из `/api/cards` + `CardImageService` ищет
`Resources/CardArt/{imageKey}`. Скачаны все 124 арта. Импорт:
`tools/import_card_art.py`.
@ -87,8 +90,10 @@
В первый ход атака запрещена; первый игрок определяется сидом.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween,
drag&drop и live-валидация декбилдера.
- Не готово: 3D-модели карт/стола (пока процедурные примитивы и арты),
звук, настройки (язык уже есть), DOTween, drag&drop и live-валидация
декбилдера. Камера боя — ограниченная орбита; анимации retire/ghost для
3D-карт ещё не перенесены.
## Запуск
@ -118,11 +123,17 @@ curl http://127.0.0.1:18080/health
& 'C:\Program Files\Unity\Hub\Editor\6000.6.6f1\Editor\Unity.exe' `
-batchmode -nographics -projectPath client `
-executeMethod Vanguard.Editor.SceneSmoke.Run
# 3D playmat sandbox
& 'C:\Program Files\Unity\Hub\Editor\6000.6.6f1\Editor\Unity.exe' `
-batchmode -nographics -projectPath client `
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
```
Клиентские проверки: **4/4** EditMode (сборка всех экранов, импорт арта,
Клиентские проверки: **17/17** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер
доски с уроном и отдыхающей картой), `SCENE_SMOKE success=True`.
3D-доски с уроном и отдыхающей картой, геометрия поля и правила
перемещения), `SCENE_SMOKE success=True`, `PLAYMAT_SMOKE success=True cards=20`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:
@ -148,6 +159,16 @@ client/Assets/Vanguard/Runtime/UI/
LoginView.cs MetaView.cs OnboardingView.cs
DeckBuilderView.cs QueueView.cs BattleView.cs
UiKit.cs Localization.cs
client/Assets/Vanguard/Runtime/Board3D/
Config/PlaymatLayoutConfig.cs ScriptableObject-раскладка
Data/BoardTypes.cs зоны, круги, CardState3D, refs
Logic/PlaymatLayout.cs геометрия, рука, поиск цели броска
Logic/BoardPlacementRules.cs Ride/Call/Move/Guard
View/GameBoard3D.cs стол, круги, пул карт, drag&drop
View/Card3D.cs карта: лицо/рубашка/торец, Rest, флип
View/BoardCameraRig.cs орбита/панорама/зум
PlaymatSandbox.cs демо без сервера
client/Assets/Vanguard/Scenes/GameBoard3D.unity песочница поля
client/Assets/Vanguard/Runtime/Battle/GameConnection.cs
client/Assets/Vanguard/Runtime/Api/ApiClient.cs + ApiContracts.cs
client/Assets/Vanguard/Editor/SceneBuilder.cs + SceneSmoke.cs
@ -171,7 +192,7 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
## Следующие шаги
1. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
2. 3D-наполнение: модели/арт, prefab-экраны, камера.
2. 3D-наполнение: модели карт/стола, prefab-экраны, анимации retire/ghost.
3. Звук и настройки, DOTween.
4. Лаунчер WPF.

View File

@ -37,7 +37,10 @@
Сцены `Login`, `Lobby`, `DeckBuilder`, `Battle` созданы;
`VanguardApp` и общие сервисы персистентны, `SceneFlow` загружает сцены.
Сейчас 3D-окружение собрано из примитивов, UI — code-generated uGUI.
Дальше: арт/модели, prefab-экраны, анимации и наполнение сцен.
Боевое поле уже 3D: `GameBoard3D` строит стол, круги, руку и стопки,
поддерживает drag&drop, Rest/фейс-даун и ограниченную камеру. Песочница —
`Assets/Vanguard/Scenes/GameBoard3D.unity`.
Дальше: 3D-модели карт/стола, prefab-экраны, анимации и наполнение сцен.
Уже перенесено: