From 657859fec1512b2465a5a9d84dbcfe28e41cc2ea Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Thu, 22 Oct 2026 16:47:34 +0500 Subject: [PATCH] Build 3D playmat with drag and drop --- client/Assets/Vanguard/Editor/SceneBuilder.cs | 65 + client/Assets/Vanguard/Editor/SceneSmoke.cs | 34 + .../Vanguard/Runtime/App/SceneEnvironment.cs | 19 +- .../Assets/Vanguard/Runtime/App/SceneFlow.cs | 1 + .../Vanguard/Runtime/App/VanguardApp.cs | 6 +- client/Assets/Vanguard/Runtime/Board3D.meta | 8 + .../Vanguard/Runtime/Board3D/Config.meta | 8 + .../Board3D/Config/PlaymatLayoutConfig.asset | 65 + .../Config/PlaymatLayoutConfig.asset.meta | 8 + .../Board3D/Config/PlaymatLayoutConfig.cs | 138 ++ .../Config/PlaymatLayoutConfig.cs.meta | 2 + .../Assets/Vanguard/Runtime/Board3D/Data.meta | 8 + .../Runtime/Board3D/Data/BoardTypes.cs | 241 +++ .../Runtime/Board3D/Data/BoardTypes.cs.meta | 2 + .../Vanguard/Runtime/Board3D/Logic.meta | 8 + .../Board3D/Logic/BoardPlacementRules.cs | 56 + .../Board3D/Logic/BoardPlacementRules.cs.meta | 2 + .../Runtime/Board3D/Logic/PlaymatLayout.cs | 215 +++ .../Board3D/Logic/PlaymatLayout.cs.meta | 2 + .../Runtime/Board3D/PlaymatSandbox.cs | 165 +++ .../Runtime/Board3D/PlaymatSandbox.cs.meta | 2 + .../Assets/Vanguard/Runtime/Board3D/View.meta | 8 + .../Runtime/Board3D/View/BoardCameraRig.cs | 132 ++ .../Board3D/View/BoardCameraRig.cs.meta | 2 + .../Runtime/Board3D/View/BoardMaterials.cs | 151 ++ .../Board3D/View/BoardMaterials.cs.meta | 2 + .../Runtime/Board3D/View/BoardMeshLibrary.cs | 164 ++ .../Board3D/View/BoardMeshLibrary.cs.meta | 2 + .../Runtime/Board3D/View/BoardSlotView.cs | 100 ++ .../Board3D/View/BoardSlotView.cs.meta | 2 + .../Runtime/Board3D/View/BoardText.cs | 73 + .../Runtime/Board3D/View/BoardText.cs.meta | 2 + .../Runtime/Board3D/View/BoardTextures.cs | 112 ++ .../Board3D/View/BoardTextures.cs.meta | 2 + .../Runtime/Board3D/View/BoardZoneView.cs | 89 ++ .../Board3D/View/BoardZoneView.cs.meta | 2 + .../Vanguard/Runtime/Board3D/View/Card3D.cs | 274 ++++ .../Runtime/Board3D/View/Card3D.cs.meta | 2 + .../Runtime/Board3D/View/GameBoard3D.cs | 1133 ++++++++++++++ .../Runtime/Board3D/View/GameBoard3D.cs.meta | 2 + .../Vanguard/Runtime/Board3D/View/Pile3D.cs | 146 ++ .../Runtime/Board3D/View/Pile3D.cs.meta | 2 + .../Assets/Vanguard/Runtime/UI/BattleView.cs | 1315 +++++------------ .../Vanguard/Runtime/UI/CardImageService.cs | 19 +- .../Assets/Vanguard/Scenes/GameBoard3D.unity | 354 +++++ .../Vanguard/Scenes/GameBoard3D.unity.meta | 7 + .../Vanguard/Tests/Editor/ClientSmokeTests.cs | 52 +- .../Vanguard/Tests/Editor/GameBoard3DTests.cs | 206 +++ .../Tests/Editor/GameBoard3DTests.cs.meta | 2 + .../Tests/Editor/PlaymatLayoutTests.cs | 164 ++ .../Tests/Editor/PlaymatLayoutTests.cs.meta | 2 + docs/HANDOFF.md | 33 + docs/SESSION-STATE.md | 49 +- docs/client-visual-direction.md | 5 +- 54 files changed, 4697 insertions(+), 968 deletions(-) create mode 100644 client/Assets/Vanguard/Runtime/Board3D.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Config.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Data.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Logic.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs.meta create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs create mode 100644 client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs.meta create mode 100644 client/Assets/Vanguard/Scenes/GameBoard3D.unity create mode 100644 client/Assets/Vanguard/Scenes/GameBoard3D.unity.meta create mode 100644 client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs create mode 100644 client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs.meta create mode 100644 client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs create mode 100644 client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs.meta diff --git a/client/Assets/Vanguard/Editor/SceneBuilder.cs b/client/Assets/Vanguard/Editor/SceneBuilder.cs index 47b2195..9595282 100644 --- a/client/Assets/Vanguard/Editor/SceneBuilder.cs +++ b/client/Assets/Vanguard/Editor/SceneBuilder.cs @@ -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("Lobby"); Create("DeckBuilder"); Create("Battle"); + EnsurePlaymatConfig(); var scenes = new List(); 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.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.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(); + 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(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(string sceneName) where T : MonoBehaviour { var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); diff --git a/client/Assets/Vanguard/Editor/SceneSmoke.cs b/client/Assets/Vanguard/Editor/SceneSmoke.cs index 02edab2..280f7f8 100644 --- a/client/Assets/Vanguard/Editor/SceneSmoke.cs +++ b/client/Assets/Vanguard/Editor/SceneSmoke.cs @@ -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(); + var cards = UnityEngine.Object.FindObjectsByType( + 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) diff --git a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs index cd2fe2d..efe1f1b 100644 --- a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs +++ b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs @@ -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; } } diff --git a/client/Assets/Vanguard/Runtime/App/SceneFlow.cs b/client/Assets/Vanguard/Runtime/App/SceneFlow.cs index 8db4d96..9c0c996 100644 --- a/client/Assets/Vanguard/Runtime/App/SceneFlow.cs +++ b/client/Assets/Vanguard/Runtime/App/SceneFlow.cs @@ -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; } diff --git a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs index 39f71a8..94003e4 100644 --- a/client/Assets/Vanguard/Runtime/App/VanguardApp.cs +++ b/client/Assets/Vanguard/Runtime/App/VanguardApp.cs @@ -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() diff --git a/client/Assets/Vanguard/Runtime/Board3D.meta b/client/Assets/Vanguard/Runtime/Board3D.meta new file mode 100644 index 0000000..4b3f662 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ee99f524fa5cd8849bef756b54365392 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config.meta b/client/Assets/Vanguard/Runtime/Board3D/Config.meta new file mode 100644 index 0000000..d07b9ad --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Config.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 88e3b2aad35fabd47aa4f089166da98c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset new file mode 100644 index 0000000..90d615b --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset @@ -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} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset.meta b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset.meta new file mode 100644 index 0000000..a961565 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c1f4dc13d22d0004ba1bddedecf71461 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs new file mode 100644 index 0000000..21903fc --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs @@ -0,0 +1,138 @@ +using UnityEngine; + +namespace Vanguard.Board3D +{ + /// + /// Настройка 3D-поля: геометрия кругов, высоты, скорости анимаций, + /// камера и палитра. Один общий ассет на Classic; раскладка не зависит + /// от того, за какое место играет локальный игрок. + /// + [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; + + /// Рантайм-конфиг, если в сцене нет ассета. + public static PlaymatLayoutConfig Default + { + get + { + if (_default == null) + { + _default = CreateInstance(); + _default.name = "PlaymatLayoutConfig (runtime)"; + _default.hideFlags = HideFlags.DontSave; + } + + return _default; + } + } + + public static PlaymatLayoutConfig CreateDefault() + { + var config = CreateInstance(); + 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); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs.meta new file mode 100644 index 0000000..6fb9629 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65b9427316955774f9c316282cfcda9a \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data.meta b/client/Assets/Vanguard/Runtime/Board3D/Data.meta new file mode 100644 index 0000000..122d8e6 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Data.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d0b844da79953df4bbe6319c79b3105a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs new file mode 100644 index 0000000..0e202b5 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Board3D +{ + /// Круг поля. Classic использует V/R, остальные — задел на будущее. + public enum CircleAttribute + { + Vanguard, + RearGuard, + Guardian, + Damage, + Soul, + Deck, + Drop, + Hand, + Bind, + GenerationBreak, + } + + /// Зона, к которой относится карта или пустой круг. + public enum BoardZone + { + Vanguard, + RearGuard, + Guardian, + Damage, + Soul, + Deck, + Drop, + Hand, + Bind, + GenerationBreak, + } + + /// Положение карты на столе: открыта/закрыта и стоит/отдыхает. + 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"); + } + + /// Ключ круга для подсветки. Slot = -1 — Авангард. + public readonly struct BoardSlotKey : IEquatable + { + 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; + } + + /// + /// Ссылка на объект стола. Для поля заполнен Slot, для руки/урона/защиты — + /// Index. InstanceId = 0 у пустого круга и закрытой карты урона. + /// + public readonly struct BoardCardRef : IEquatable + { + 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()); + } + + /// Куда можно уронить карту при drag&drop. + 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); + } + + /// Временное состояние отрисовки, которое приходит из боевого экрана. + public sealed class BoardRenderState + { + public HashSet Highlights { get; } = new HashSet(); + public HashSet DropTargets { get; } = new HashSet(); + public bool GuardianDropAllowed { get; set; } + public HashSet SelectedHandInstanceIds { get; } = new HashSet(); + 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; } + } + + /// Текстура лица и акцентный цвет карты. Front может быть null. + 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); +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs.meta new file mode 100644 index 0000000..8a17a95 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d62441ca1c992648925ecfd2beafd34 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic.meta b/client/Assets/Vanguard/Runtime/Board3D/Logic.meta new file mode 100644 index 0000000..8f6e267 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Logic.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 94d93f21bd6f5bd4b94fda0ef65ea27d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs b/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs new file mode 100644 index 0000000..2da1d3d --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs @@ -0,0 +1,56 @@ +namespace Vanguard.Board3D +{ + /// + /// Локальные правила раскладки для UX. Сервер остаётся авторитарным: + /// эти проверки нужны для подсветки и drag&drop, а не для начисления + /// результата. + /// + 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; + + /// Колонки Classic: левая 0↔3, правая 2↔4; у центра пары нет. + public static bool SameColumn(int fromSlot, int toSlot) => + (fromSlot, toSlot) is (0, 3) or (3, 0) or (2, 4) or (4, 2); + + /// Call и Ride входят стоя и открытыми. + public static CardState3D EnterFromHand(CircleAttribute circle) => circle switch + { + CircleAttribute.Vanguard => CardState3D.FaceUpStand, + CircleAttribute.RearGuard => CardState3D.FaceUpStand, + CircleAttribute.Guardian => CardState3D.FaceUpStand, + _ => CardState3D.FaceUpStand, + }; + + /// Перемещение RC→RC сохраняет Rest и открытость. + public static CardState3D PreserveOnMove(CardState3D state) => state; + + public static BoardZone ZoneOfSlot(int slot) => + slot == PlaymatLayout.VanguardSlot + ? BoardZone.Vanguard + : BoardZone.RearGuard; + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs.meta new file mode 100644 index 0000000..a7d0f99 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/BoardPlacementRules.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 511bb3e3238227e4eb651ed4d7d5d386 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs new file mode 100644 index 0000000..117a5db --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs @@ -0,0 +1,215 @@ +using UnityEngine; + +namespace Vanguard.Board3D +{ + /// + /// Геометрия стола. Локальное место 0 — ближняя половина (локальный + /// игрок), 1 — дальняя (противник). Позиции всегда зеркальны, поэтому + /// игрок за второе место видит своё поле внизу. + /// + 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; + + /// Z-направление от центра к половине локального места. + public float ZSign(int localSeat) => localSeat == 0 ? -1f : 1f; + + /// Поворот лица карты: своя половина читается с -Z, чужая — с +Z. + 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); + + /// Круги урона: две колонки по три карты на боку. + 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); + } + + /// Колода и дроп сбоку от поля, у своего края. + 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); + } + + /// Душа — визуальная стопка под кругом V, чуть ближе к игроку. + public Vector3 SoulPosition(int localSeat) + { + var z = ZSign(localSeat) * (_config.frontRowZ + 0.55f); + return new Vector3(0f, 0.002f, z); + } + + /// Общий круг защиты: своя половина ближе к своему полю. + 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); + } + + /// Веер руки перед локальным игроком. + 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); + } + + /// + /// Ближайшая цель для броска карты. Возвращает false, если карта + /// отпущена мимо кругов и общего круга защиты. + /// + 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; } + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs.meta new file mode 100644 index 0000000..412ee11 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ccfc6e38ed67c204f9b17de5edccbf6a \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs new file mode 100644 index 0000000..668fee1 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using UnityEngine; +using Vanguard.App; +using Vanguard.Shared.Protocol; +using Vanguard.UI; + +namespace Vanguard.Board3D +{ + /// + /// Демо-сцена 3D-поля: раскладывает карты без сервера, чтобы проверять + /// геометрию, drag&drop, rest, закрытые карты и общий круг защиты. + /// + 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 + { + Card("BT01/002EN", 11), + Card("BT01/003EN", 12), + Card("BT01/004EN", 13), + Card("BT01/005EN", 14), + Card("BT01/006EN", 15), + }, + Guardian = new List + { + Card("BT01/007EN", 21), + Card("BT01/008EN", 22), + }, + Soul = new List + { + Card("BT01/009EN", 31), + Card("BT01/010EN", 32), + }, + FaceDownDamage = 2, + FaceUpDamage = new List + { + Card("BT01/011EN", 41), + Card("BT01/012EN", 42), + }, + DeckCount = 38, + Drop = new List { 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(), + Guardian = new List(), + Soul = new List { Card("BT01/021EN", 131) }, + FaceDownDamage = 1, + FaceUpDamage = new List { Card("BT01/022EN", 141) }, + DeckCount = 40, + Drop = new List(), + }; + 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 { self, opponent }, + Clock = new ClockView(), + LegalIntents = new List(), + }; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs.meta new file mode 100644 index 0000000..18ba237 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 89fedfc3c1f371c448b4187e210d36fb \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View.meta b/client/Assets/Vanguard/Runtime/Board3D/View.meta new file mode 100644 index 0000000..28e707a --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1db33b73c2d4ad645b7ad7a7e6aaff6b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs new file mode 100644 index 0000000..36b8899 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs @@ -0,0 +1,132 @@ +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem; + +namespace Vanguard.Board3D +{ + /// + /// Ограниченная орбита камеры: ПКМ — поворот, СКМ — панорама, + /// колесо — зум. Левая кнопка остаётся картам и UI. + /// + 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(); + if (camera != null) + camera.fieldOfView = _config.cameraFov; + } + + private static bool IsPointerOverUi() + { + var eventSystem = EventSystem.current; + return eventSystem != null && eventSystem.IsPointerOverGameObject(); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs.meta new file mode 100644 index 0000000..7bfc876 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardCameraRig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30bd2e29295c0b241bb1f29e9dc73ca8 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs new file mode 100644 index 0000000..12b263c --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs @@ -0,0 +1,151 @@ +using UnityEngine; +using UnityEngine.Rendering; + +namespace Vanguard.Board3D +{ + /// Общие материалы стола. Текстуры лиц задаются property block. + 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; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs.meta new file mode 100644 index 0000000..5529b78 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMaterials.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee026e3b2fff0b548b3a06f4c6daab77 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs new file mode 100644 index 0000000..dd8e516 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Vanguard.Board3D +{ + /// Плоские рамки кругов и зон в плоскости XZ. + internal static class BoardMeshLibrary + { + private static readonly Dictionary Cache = + new Dictionary(); + + 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(16); + var triangles = new List(48); + var uvs = new List(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 vertices, + ICollection triangles, + ICollection 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); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs.meta new file mode 100644 index 0000000..15b702e --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardMeshLibrary.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b08a1484f6888564c83043946c9467b6 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs new file mode 100644 index 0000000..c7f1504 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs @@ -0,0 +1,100 @@ +using UnityEngine; + +namespace Vanguard.Board3D +{ + /// Круг поля: рамка, коллайдер и подпись V/R. + 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(); + 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(); + filter.sharedMesh = BoardMeshLibrary.Frame( + config.cardWidth * 1.34f, + config.cardHeight * 1.34f, + Mathf.Max(0.018f, config.cardWidth * 0.035f)); + + var renderer = go.AddComponent(); + renderer.sharedMaterial = BoardMaterials.Highlight; + BoardMaterials.SetShadows(renderer, false); + view._renderer = renderer; + view.ApplyColor(false); + + var collider = go.AddComponent(); + 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); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs.meta new file mode 100644 index 0000000..94d97bc --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardSlotView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69d07f80399a37a4b9faef94f0c43fde \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs new file mode 100644 index 0000000..949a400 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs @@ -0,0 +1,73 @@ +using UnityEngine; +using UnityEngine.Rendering; + +namespace Vanguard.Board3D +{ + /// Плоские подписи на столе без TMP и Canvas. + internal static class BoardText + { + private static Font _font; + + public static Font Font + { + get + { + if (_font != null) + return _font; + + _font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + if (_font == null) + _font = Resources.GetBuiltinResource("Arial.ttf"); + if (_font == null) + _font = Resources.GetBuiltinResource("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(); + 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(); + 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; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs.meta new file mode 100644 index 0000000..1fd065a --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardText.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5622bffba113d9843b24961dd5dbf81f \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs new file mode 100644 index 0000000..f4f11bd --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs @@ -0,0 +1,112 @@ +using UnityEngine; + +namespace Vanguard.Board3D +{ + /// Процедурная рубашка и запасное лицо карты без внешних ассетов. + 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; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs.meta new file mode 100644 index 0000000..4ac72b7 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardTextures.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 41706474af643f54c81c3469dea25f40 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs b/client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs new file mode 100644 index 0000000..0dbcebc --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs @@ -0,0 +1,89 @@ +using UnityEngine; + +namespace Vanguard.Board3D +{ + /// Плоская зона стола: общий круг защиты, урон, душа. + 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(); + view._baseColor = color; + view._highlightColor = highlightColor; + view._block = new MaterialPropertyBlock(); + + var filter = go.AddComponent(); + filter.sharedMesh = BoardMeshLibrary.Frame(width, depth, border); + + var renderer = go.AddComponent(); + 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); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs.meta new file mode 100644 index 0000000..737369c --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/BoardZoneView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b56ad9ea684c45d418241b20af51c21a \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs new file mode 100644 index 0000000..6546e03 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs @@ -0,0 +1,274 @@ +using UnityEngine; +using UnityEngine.Rendering; + +namespace Vanguard.Board3D +{ + /// + /// Карта на столе: тонкая коробка с отдельными материалами лица, рубашки + /// и торца. Rest — поворот вокруг Y, закрытость — переворот вокруг X. + /// + 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(); + 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(); + _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(); + frontFilter.sharedMesh = BoardMeshLibrary.CardFace(width, height, front: true); + _front = frontGo.AddComponent(); + _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(); + backFilter.sharedMesh = BoardMeshLibrary.CardFace(width, height, front: false); + _back = backGo.AddComponent(); + _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(); + filter.sharedMesh = BoardMeshLibrary.Frame( + width * 1.16f, + height * 1.16f, + Mathf.Max(0.025f, width * 0.035f)); + _highlight = highlightGo.AddComponent(); + _highlight.sharedMaterial = BoardMaterials.Highlight; + BoardMaterials.SetShadows(_highlight, false); + _highlight.gameObject.SetActive(false); + + _collider = gameObject.AddComponent(); + _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(); + if (collider == null) + return; + + if (Application.isPlaying) + Destroy(collider); + else + DestroyImmediate(collider); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs.meta new file mode 100644 index 0000000..9721e61 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a8b5670bd4e091143b3d4752c0bb2b1a \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs new file mode 100644 index 0000000..50af016 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -0,0 +1,1133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem; +using Vanguard.Shared.Protocol; + +namespace Vanguard.Board3D +{ + /// + /// 3D-поле: стол, круги, общий круг защиты, стопки, веер руки и карты. + /// Отрисовка полностью процедурная; материалы и меши переиспользуются. + /// + public sealed class GameBoard3D : MonoBehaviour + { + private const string RootName = "GameBoard3D"; + + private static GameBoard3D _instance; + + private readonly List _slots = new List(); + private readonly Dictionary _slotByKey = + new Dictionary(); + private readonly Dictionary _cards = + new Dictionary(StringComparer.Ordinal); + private readonly Stack _pool = new Stack(); + private readonly List _piles = new List(); + private readonly BoardZoneView[] _damageZones = new BoardZoneView[2]; + private readonly HashSet _activeHighlights = new HashSet(); + private readonly HashSet _validDropSlots = new HashSet(); + private bool _guardianDropAllowed; + + private PlaymatLayoutConfig _config; + private PlaymatLayout _layout; + private MatchView _view; + private int _selfPlayer; + private bool _hasRendered; + private bool _built; + + private Transform _slotsRoot; + private Transform _cardsRoot; + private Transform _pilesRoot; + private Transform _zonesRoot; + private Camera _camera; + private BoardCameraRig _cameraRig; + private bool _ownsRig; + + private BoardZoneView _guardianZone; + private BoardZoneView _dropIndicator; + private Pile3D _deckSelf; + private Pile3D _deckOpponent; + private Pile3D _dropSelf; + private Pile3D _dropOpponent; + private Pile3D _soulSelf; + private Pile3D _soulOpponent; + + private enum PointerMode + { + None, + Card, + Slot, + Pile, + } + + private PointerMode _mode; + private Card3D _pressedCard; + private BoardCardRef _pressedCardRef; + private BoardSlotView _pressedSlot; + private Pile3D _pressedPile; + private Vector2 _pressPosition; + private bool _dragging; + private BoardDropTarget? _dragTarget; + + public PlaymatLayoutConfig Config => _config; + public PlaymatLayout Layout => _layout; + public int SelfPlayer => _selfPlayer; + public BoardArtProvider ArtProvider { get; set; } + + public event Action CardClicked; + public event Action SlotClicked; + public event Action CardDropped; + public event Action PileClicked; + + public static GameBoard3D Create(Transform parent, PlaymatLayoutConfig config = null) + { + if (_instance != null) + { + var previous = _instance.gameObject; + if (Application.isPlaying) + Destroy(previous); + else + DestroyImmediate(previous); + } + + var go = new GameObject(RootName); + if (parent != null) + go.transform.SetParent(parent, false); + + var board = go.AddComponent(); + board.Initialize(config); + _instance = board; + return board; + } + + public void Initialize(PlaymatLayoutConfig config) + { + if (_built) + return; + + _config = config != null ? config : PlaymatLayoutConfig.Default; + _layout = new PlaymatLayout(_config); + _selfPlayer = 0; + _built = true; + + BuildTable(); + BuildSlots(); + BuildZones(); + BuildPiles(); + BuildDropIndicator(); + + _cardsRoot = new GameObject("Cards").transform; + _cardsRoot.SetParent(transform, false); + + EnsureCamera(); + SetVisible(false); + } + + public void SetSelfPlayer(int player) + { + player = player == 0 ? 0 : 1; + if (_selfPlayer == player) + return; + + _selfPlayer = player; + foreach (var slot in _slots) + slot.SetPlayer(LocalToPlayer(slot.LocalSeat)); + + _deckSelf.SetPlayer(LocalToPlayer(0)); + _dropSelf.SetPlayer(LocalToPlayer(0)); + _soulSelf.SetPlayer(LocalToPlayer(0)); + _deckOpponent.SetPlayer(LocalToPlayer(1)); + _dropOpponent.SetPlayer(LocalToPlayer(1)); + _soulOpponent.SetPlayer(LocalToPlayer(1)); + _hasRendered = false; + } + + public void SetVisible(bool visible) + { + if (!visible) + { + CancelPointer(); + _hasRendered = false; + } + else + { + EnsureCamera(); + } + + gameObject.SetActive(visible); + if (_cameraRig != null) + _cameraRig.enabled = visible; + } + + public void Render(MatchView view, BoardRenderState state) + { + if (!_built) + return; + + if (view == null) + { + ClearCards(); + _view = null; + return; + } + + SetSelfPlayer(view.You); + _view = view; + state ??= new BoardRenderState(); + + var specs = new List(40); + BuildSpecs(view, state, specs); + + var used = new HashSet(StringComparer.Ordinal); + var immediateAll = !_hasRendered; + + foreach (var spec in specs) + { + used.Add(spec.Key); + if (!_cards.TryGetValue(spec.Key, out var card) || card == null) + { + card = RentCard(); + _cards[spec.Key] = card; + } + + var changed = !card.gameObject.activeSelf + || card.Ref.InstanceId != spec.Ref.InstanceId + || card.Ref.Zone != spec.Ref.Zone; + card.Bind(spec.Ref); + card.SetArt(spec.Art.Front, spec.Art.Accent); + card.SetPose( + spec.Position, + spec.BaseRotation, + spec.FaceUp, + spec.Rested, + immediateAll || changed); + card.SetHighlight(spec.Highlight); + } + + if (_cards.Count > 0) + { + var stale = _cards + .Where(pair => !used.Contains(pair.Key)) + .Select(pair => pair.Key) + .ToList(); + foreach (var key in stale) + { + ReleaseCard(_cards[key]); + _cards.Remove(key); + } + } + + UpdatePiles(view); + UpdateSlots(state); + _hasRendered = true; + } + + public void Clear() + { + ClearCards(); + _view = null; + _hasRendered = false; + } + + private void BuildTable() + { + var width = (_config.boardHalfWidth * 2f) + 2.2f; + var depth = (_config.boardHalfLength * 2f) + 1.7f; + CreateBox( + "Table", + new Vector3(0f, -0.06f, 0f), + new Vector3(width, 0.12f, depth), + _config.tableColor); + + var matWidth = (_config.boardHalfWidth * 2f) - 0.45f; + var matDepth = _config.boardHalfLength - 0.35f; + + CreateBox( + "Mat Self", + new Vector3(0f, 0.002f, -(_config.boardHalfLength * 0.5f) - 0.05f), + new Vector3(matWidth, 0.008f, matDepth), + _config.matSelfColor); + CreateBox( + "Mat Opponent", + new Vector3(0f, 0.002f, (_config.boardHalfLength * 0.5f) + 0.05f), + new Vector3(matWidth, 0.008f, matDepth), + _config.matOpponentColor); + + var divider = BoardMaterials.CreateLit(_config.lineColor, 1.2f); + CreateBox( + "Center Line", + new Vector3(0f, 0.011f, 0f), + new Vector3(width - 0.3f, 0.012f, 0.055f), + divider); + + var railMaterial = BoardMaterials.CreateLit(_config.lineColor, 0.9f); + CreateBox( + "Rail Left", + new Vector3(-(_config.boardHalfWidth + 1.02f), 0.04f, 0f), + new Vector3(0.07f, 0.08f, depth), + railMaterial); + CreateBox( + "Rail Right", + new Vector3(_config.boardHalfWidth + 1.02f, 0.04f, 0f), + new Vector3(0.07f, 0.08f, depth), + railMaterial); + CreateBox( + "Rail Near", + new Vector3(0f, 0.04f, -(_config.boardHalfLength + 0.79f)), + new Vector3(width, 0.08f, 0.07f), + railMaterial); + CreateBox( + "Rail Far", + new Vector3(0f, 0.04f, _config.boardHalfLength + 0.79f), + new Vector3(width, 0.08f, 0.07f), + railMaterial); + } + + private void BuildSlots() + { + _slotsRoot = new GameObject("Slots").transform; + _slotsRoot.SetParent(transform, false); + + for (var localSeat = 0; localSeat < 2; localSeat++) + { + for (var slot = PlaymatLayout.VanguardSlot; + slot < PlaymatLayout.RearGuardCount; + slot++) + { + var view = BoardSlotView.Create( + _slotsRoot, + _config, + localSeat, + LocalToPlayer(localSeat), + slot, + _layout); + _slots.Add(view); + _slotByKey[view.Key] = view; + } + } + } + + private void BuildZones() + { + _zonesRoot = new GameObject("Zones").transform; + _zonesRoot.SetParent(transform, false); + + var guardianDepth = (_config.guardianRowGap * 2f) + 0.8f; + _guardianZone = BoardZoneView.Create( + _zonesRoot, + "Guardian Zone", + new Vector3(0f, 0.012f, 0f), + _config.guardianMaxWidth + 1.4f, + guardianDepth, + Mathf.Max(0.022f, _config.cardWidth * 0.04f), + _config.guardianColor, + "GUARDIAN", + 0.11f, + -(guardianDepth * 0.5f) - 0.2f, + new Color(0.72f, 0.92f, 1f, 0.75f), + _config.highlightColor); + + for (var localSeat = 0; localSeat < 2; localSeat++) + { + var zSign = _layout.ZSign(localSeat); + var sideSign = localSeat == 0 ? -1f : 1f; + var zone = BoardZoneView.Create( + _zonesRoot, + "Damage Zone " + localSeat, + new Vector3( + sideSign * (_config.damageX + 0.02f), + 0.011f, + zSign * (_config.frontRowZ + 1.15f)), + 1.72f, + 2.25f, + Mathf.Max(0.018f, _config.cardWidth * 0.03f), + _config.damageColor, + "DAMAGE", + 0.1f, + -zSign * 1.32f, + new Color(1f, 0.78f, 0.76f, 0.75f), + _config.highlightColor); + _damageZones[localSeat] = zone; + } + } + + private void BuildPiles() + { + _pilesRoot = new GameObject("Piles").transform; + _pilesRoot.SetParent(transform, false); + + _deckSelf = CreatePile(BoardZone.Deck, 0, 0, "DECK", new Color(0.20f, 0.34f, 0.60f)); + _deckOpponent = CreatePile(BoardZone.Deck, 1, 1, "DECK", new Color(0.60f, 0.26f, 0.26f)); + _dropSelf = CreatePile(BoardZone.Drop, 0, 0, "DROP", new Color(0.30f, 0.30f, 0.40f)); + _dropOpponent = CreatePile(BoardZone.Drop, 1, 1, "DROP", new Color(0.40f, 0.28f, 0.28f)); + _soulSelf = CreatePile(BoardZone.Soul, 0, 0, "SOUL", _config.soulColor); + _soulOpponent = CreatePile(BoardZone.Soul, 1, 1, "SOUL", _config.soulColor); + } + + private Pile3D CreatePile( + BoardZone zone, + int player, + int localSeat, + string title, + Color accent) + { + var position = zone switch + { + BoardZone.Deck => _layout.DeckPosition(localSeat), + BoardZone.Drop => _layout.DropPosition(localSeat), + _ => _layout.SoulPosition(localSeat), + }; + + var pile = Pile3D.Create( + _pilesRoot, + _config, + zone, + player, + position, + title, + accent); + _piles.Add(pile); + return pile; + } + + private void BuildDropIndicator() + { + _dropIndicator = BoardZoneView.Create( + transform, + "Drop Indicator", + new Vector3(0f, 0.014f, 0f), + _config.cardWidth * 1.4f, + _config.cardHeight * 1.4f, + 0.025f, + _config.highlightColor, + null, + 0.1f, + 0f, + Color.white, + _config.selectedColor); + _dropIndicator.gameObject.SetActive(false); + } + + private void EnsureCamera() + { + if (_camera == null) + _camera = Camera.main; + if (_camera == null) + return; + + if (_cameraRig == null) + { + _cameraRig = _camera.GetComponent(); + if (_cameraRig == null) + { + _cameraRig = _camera.gameObject.AddComponent(); + _ownsRig = true; + } + } + + _cameraRig.Configure(_config); + } + + private void BuildSpecs( + MatchView view, + BoardRenderState state, + List specs) + { + foreach (var player in view.Players) + { + var localSeat = _layout.ToLocalSeat(player.Index, _selfPlayer); + AddFieldSpecs(player, localSeat, state, specs); + AddGuardianSpecs(view, player, localSeat, specs); + AddDamageSpecs(player, localSeat, state, specs); + } + + AddHandSpecs(view, state, specs); + } + + private void AddFieldSpecs( + PlayerView player, + int localSeat, + BoardRenderState state, + List specs) + { + AddFieldCard(player, localSeat, PlaymatLayout.VanguardSlot, player.Vanguard, state, specs); + + if (player.RearGuards == null) + return; + + for (var slot = 0; slot < player.RearGuards.Length; slot++) + AddFieldCard(player, localSeat, slot, player.RearGuards[slot], state, specs); + } + + private void AddFieldCard( + PlayerView player, + int localSeat, + int slot, + CardView card, + BoardRenderState state, + List specs) + { + if (card == null) + return; + + var key = new BoardSlotKey(player.Index, slot); + specs.Add(new CardSpec + { + Key = "F:" + player.Index + ":" + slot, + Ref = BoardCardRef.Field(player.Index, slot, card), + Art = ResolveArt(card.PrintingId), + Position = _layout.CirclePosition(localSeat, slot), + BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f), + FaceUp = card.FaceUp, + Rested = card.Rested, + Highlight = state.Highlights.Contains(key) + ? _config.highlightColor + : (Color?)null, + }); + } + + private void AddGuardianSpecs( + MatchView view, + PlayerView player, + int localSeat, + List specs) + { + var guardians = player.Guardian ?? new List(); + var battleCards = view.Battle != null && view.Battle.AttackerPlayer != player.Index + ? view.Battle.GuardCards ?? new List() + : new List(); + var total = guardians.Count + battleCards.Count; + if (total == 0) + return; + + for (var i = 0; i < guardians.Count; i++) + { + var card = guardians[i]; + specs.Add(new CardSpec + { + Key = "G:" + player.Index + ":" + card.InstanceId, + Ref = BoardCardRef.Guardian(player.Index, i, card), + Art = ResolveArt(card.PrintingId), + Position = _layout.GuardianPosition(localSeat, i, total), + BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f), + FaceUp = true, + Rested = false, + }); + } + + for (var i = 0; i < battleCards.Count; i++) + { + var card = battleCards[i]; + specs.Add(new CardSpec + { + Key = "BG:" + player.Index + ":" + card.InstanceId, + Ref = BoardCardRef.Guardian(player.Index, guardians.Count + i, card), + Art = ResolveArt(card.PrintingId), + Position = _layout.GuardianPosition( + localSeat, + guardians.Count + i, + total), + BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f), + FaceUp = true, + Rested = false, + }); + } + } + + private void AddDamageSpecs( + PlayerView player, + int localSeat, + BoardRenderState state, + List specs) + { + var faceUp = player.FaceUpDamage ?? new List(); + var total = Mathf.Min( + PlaymatLayout.DamageSlots, + faceUp.Count + Mathf.Max(0, player.FaceDownDamage)); + + for (var i = 0; i < total; i++) + { + var isUp = i < faceUp.Count; + var card = isUp ? faceUp[i] : null; + var highlight = DamageHighlight(state, i); + specs.Add(new CardSpec + { + Key = "D:" + player.Index + ":" + i, + Ref = BoardCardRef.Damage(player.Index, i, card, isUp), + Art = isUp + ? ResolveArt(card.PrintingId) + : BoardArt.Fallback(_config.damageColor), + Position = _layout.DamagePosition(localSeat, i), + BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f), + FaceUp = isUp, + Rested = true, + Highlight = highlight, + }); + } + } + + private Color? DamageHighlight(BoardRenderState state, int index) + { + if (!state.DamageChoice && !state.AbilityDamageTargeting) + return null; + + if (state.AbilityDamageTargeting + && state.AbilityDamageIndex == index) + { + return _config.selectedColor; + } + + return _config.highlightColor; + } + + private void AddHandSpecs( + MatchView view, + BoardRenderState state, + List specs) + { + var self = view.Players.FirstOrDefault(player => player.Index == view.You); + if (self?.Hand == null) + return; + + var count = self.Hand.Count; + for (var i = 0; i < count; i++) + { + var card = self.Hand[i]; + var pose = _layout.GetHandPose(i, count); + Color? highlight = null; + if (state.SelectedHandInstanceIds.Contains(card.InstanceId)) + highlight = _config.selectedColor; + else if (state.MulliganSelecting) + highlight = _config.highlightColor; + + specs.Add(new CardSpec + { + Key = "H:" + card.InstanceId, + Ref = BoardCardRef.Hand(self.Index, i, card), + Art = ResolveArt(card.PrintingId), + Position = pose.Position, + BaseRotation = Quaternion.Euler(_config.handTilt, pose.Yaw, 0f), + FaceUp = true, + Rested = false, + Highlight = highlight, + }); + } + } + + private void UpdatePiles(MatchView view) + { + var self = view.Players.FirstOrDefault(player => player.Index == view.You); + var opponent = view.Players.FirstOrDefault(player => player.Index != view.You); + + 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); + + 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); + } + } + + private void UpdatePileSide( + PlayerView player, + Pile3D deck, + Pile3D drop, + Pile3D soul) + { + if (deck == null) + return; + + if (player == null) + { + deck.SetCount(0, null, "DECK", Color.white); + drop.SetCount(0, null, "DROP", Color.white); + soul.SetCount(0, null, "SOUL", _config.soulColor); + return; + } + + deck.SetCount( + player.DeckCount, + null, + "DECK", + new Color(0.20f, 0.34f, 0.60f)); + + var topDrop = player.Drop != null && player.Drop.Count > 0 + ? player.Drop[player.Drop.Count - 1] + : null; + drop.SetCount( + player.Drop?.Count ?? 0, + topDrop != null ? ResolveArt(topDrop.PrintingId).Front : null, + "DROP", + new Color(0.30f, 0.30f, 0.40f)); + + var topSoul = player.Soul != null && player.Soul.Count > 0 + ? player.Soul[player.Soul.Count - 1] + : null; + soul.SetCount( + player.Soul?.Count ?? 0, + topSoul != null ? ResolveArt(topSoul.PrintingId).Front : null, + "SOUL", + _config.soulColor); + } + + private void UpdateSlots(BoardRenderState state) + { + _activeHighlights.Clear(); + foreach (var key in state.Highlights) + _activeHighlights.Add(key); + + _validDropSlots.Clear(); + foreach (var key in state.DropTargets) + _validDropSlots.Add(key); + _guardianDropAllowed = state.GuardianDropAllowed; + + ApplySlotHighlights(); + } + + private void ApplySlotHighlights() + { + foreach (var slot in _slots) + { + var highlighted = _activeHighlights.Contains(slot.Key) + || (_dragging && _validDropSlots.Contains(slot.Key)); + slot.SetHighlighted(highlighted); + } + + if (_guardianZone != null) + { + _guardianZone.SetHighlighted(_dragging && _guardianDropAllowed); + } + } + + private void Update() + { + var delta = Time.unscaledDeltaTime; + foreach (var card in _cards.Values) + { + if (card != null) + card.Tick(delta); + } + + HandlePointer(); + } + + private void HandlePointer() + { + if (!_hasRendered && _view == null) + return; + + if (!TryReadPointer(out var pointer, out var pressed, out var held, out var released)) + return; + + if (pressed) + OnPointerPressed(pointer); + else if (held && _mode == PointerMode.Card && !_dragging) + OnPointerHeld(pointer); + else if (released) + OnPointerReleased(pointer); + } + + private static bool TryReadPointer( + out Vector2 pointer, + out bool pressed, + out bool held, + out bool released) + { + pointer = default; + pressed = false; + held = false; + released = false; + + var mouse = Mouse.current; + if (mouse != null) + { + pointer = mouse.position.ReadValue(); + pressed = mouse.leftButton.wasPressedThisFrame; + held = mouse.leftButton.isPressed; + released = mouse.leftButton.wasReleasedThisFrame; + return true; + } + + var touch = Touchscreen.current; + if (touch == null) + return false; + + var primary = touch.primaryTouch; + pointer = primary.position.ReadValue(); + pressed = primary.press.wasPressedThisFrame; + held = primary.press.isPressed; + released = primary.press.wasReleasedThisFrame; + return true; + } + + private void OnPointerPressed(Vector2 pointer) + { + CancelPointer(); + if (IsPointerOverUi()) + return; + + var ray = GetRay(pointer); + if (!TryRaycast(ray, out var card, out var slot, out var pile)) + return; + + _pressPosition = pointer; + if (card != null) + { + _mode = PointerMode.Card; + _pressedCard = card; + _pressedCardRef = card.Ref; + return; + } + + if (slot != null) + { + _mode = PointerMode.Slot; + _pressedSlot = slot; + return; + } + + if (pile != null) + { + _mode = PointerMode.Pile; + _pressedPile = pile; + } + } + + private void OnPointerHeld(Vector2 pointer) + { + if (_pressedCard == null || _pressedCardRef.IsEmpty) + return; + + if (!_pressedCardRef.IsField && !_pressedCardRef.IsHand) + return; + + if (Vector2.Distance(pointer, _pressPosition) + < Mathf.Max(2f, _config.dragThresholdPixels)) + return; + + _dragging = true; + _pressedCard.BeginDrag(); + ApplySlotHighlights(); + UpdateDrag(pointer); + } + + private void OnPointerReleased(Vector2 pointer) + { + if (_dragging) + { + UpdateDrag(pointer); + FinishDrag(pointer); + return; + } + + if (_mode == PointerMode.Card && _pressedCard != null) + { + CardClicked?.Invoke(_pressedCardRef); + } + else if (_mode == PointerMode.Slot && _pressedSlot != null) + { + SlotClicked?.Invoke(_pressedSlot.Ref); + } + else if (_mode == PointerMode.Pile && _pressedPile != null) + { + PileClicked?.Invoke(_pressedPile.Ref); + } + + CancelPointer(); + } + + private void UpdateDrag(Vector2 pointer) + { + if (!_dragging || _pressedCard == null) + return; + + var ray = GetRay(pointer); + var plane = new Plane(Vector3.up, new Vector3(0f, _config.cardLift, 0f)); + if (!plane.Raycast(ray, out var enter)) + return; + + var point = ray.GetPoint(enter); + point.x = Mathf.Clamp(point.x, -_config.boardHalfWidth, _config.boardHalfWidth); + point.z = Mathf.Clamp(point.z, -_config.boardHalfLength, _config.boardHalfLength); + _pressedCard.DragTo(point); + + if (_layout.TryFindDropTarget(point, _selfPlayer, out var target)) + { + _dragTarget = target; + ShowDropIndicator(target); + var key = new BoardSlotKey(target.Player, target.Slot); + var valid = target.Zone == BoardZone.Guardian + ? _guardianDropAllowed + : _validDropSlots.Contains(key); + _dropIndicator.SetColor(valid ? _config.selectedColor : _config.damageColor); + } + else + { + _dragTarget = null; + _dropIndicator.gameObject.SetActive(false); + } + } + + private void FinishDrag(Vector2 pointer) + { + var card = _pressedCard; + var source = _pressedCardRef; + var target = _dragTarget; + _dragging = false; + _dragTarget = null; + _dropIndicator.gameObject.SetActive(false); + + if (card == null) + { + CancelPointer(); + return; + } + + if (IsPointerOverUi() || !target.HasValue) + { + card.ReturnToOrigin(); + CancelPointer(); + return; + } + + var targetValue = target.Value; + var targetRef = targetValue.ToRef(); + + // Атака — это клик по цели, карта остаётся на своём круге. + if ((source.IsField && targetValue.Player != source.Player) + || (source.IsHand && targetValue.Player != source.Player + && targetValue.Zone != BoardZone.Guardian)) + { + card.ReturnToOrigin(); + } + else + { + var localSeat = _layout.ToLocalSeat(targetValue.Player, _selfPlayer); + var position = targetValue.Zone == BoardZone.Guardian + ? _layout.GuardianPosition(localSeat, 0, 1) + : _layout.CirclePosition(localSeat, targetValue.Slot); + var restYaw = !source.IsHand && card.IsRested ? 90f : 0f; + var rotation = Quaternion.Euler( + 0f, + _layout.BaseYaw(localSeat) + restYaw, + 0f); + card.DropAt(position, rotation); + } + + CardDropped?.Invoke(source, targetRef); + CancelPointer(); + } + + private void ShowDropIndicator(BoardDropTarget target) + { + var localSeat = _layout.ToLocalSeat(target.Player, _selfPlayer); + var position = target.Zone == BoardZone.Guardian + ? _layout.GuardianPosition(localSeat, 0, 1) + : _layout.CirclePosition(localSeat, target.Slot); + _dropIndicator.transform.localPosition = new Vector3( + position.x, + 0.014f, + position.z); + _dropIndicator.gameObject.SetActive(true); + } + + private bool TryRaycast( + Ray ray, + out Card3D card, + out BoardSlotView slot, + out Pile3D pile) + { + card = null; + slot = null; + pile = null; + + var hits = Physics.RaycastAll(ray, 100f, ~0, QueryTriggerInteraction.Ignore); + if (hits.Length == 0) + return false; + + Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); + foreach (var hit in hits) + { + if (hit.collider == null) + continue; + + card = hit.collider.GetComponentInParent(); + if (card != null && card.gameObject.activeInHierarchy) + return true; + + slot = hit.collider.GetComponentInParent(); + if (slot != null) + return true; + + pile = hit.collider.GetComponentInParent(); + if (pile != null) + return true; + } + + return false; + } + + private Ray GetRay(Vector2 screenPoint) + { + if (_camera == null) + _camera = Camera.main; + return _camera != null + ? _camera.ScreenPointToRay(screenPoint) + : new Ray(Vector3.up * 10f, Vector3.down); + } + + private static bool IsPointerOverUi() + { + var eventSystem = EventSystem.current; + return eventSystem != null && eventSystem.IsPointerOverGameObject(); + } + + private Card3D RentCard() + { + while (_pool.Count > 0) + { + var pooled = _pool.Pop(); + if (pooled == null) + continue; + pooled.gameObject.SetActive(true); + return pooled; + } + + if (_config.cardPrefab != null) + { + var instance = Instantiate(_config.cardPrefab, _cardsRoot); + return instance; + } + + if (_cardsRoot == null) + { + _cardsRoot = new GameObject("Cards").transform; + _cardsRoot.SetParent(transform, false); + } + + return Card3D.Create(_cardsRoot, _config); + } + + private void ReleaseCard(Card3D card) + { + if (card == null) + return; + card.SetHighlight(null); + card.gameObject.SetActive(false); + _pool.Push(card); + } + + private void ClearCards() + { + foreach (var card in _cards.Values) + ReleaseCard(card); + _cards.Clear(); + } + + private void CancelPointer() + { + if (_pressedCard != null && _dragging) + _pressedCard.ReturnToOrigin(); + _mode = PointerMode.None; + _pressedCard = null; + _pressedSlot = null; + _pressedPile = null; + _dragging = false; + _dragTarget = null; + if (_dropIndicator != null) + _dropIndicator.gameObject.SetActive(false); + ApplySlotHighlights(); + } + + private BoardArt ResolveArt(string printingId) + { + if (ArtProvider != null) + { + var art = ArtProvider(printingId); + if (art != null) + return art; + } + + return BoardArt.Fallback(BoardTextures.AccentFor(printingId)); + } + + private int LocalToPlayer(int localSeat) => + _layout.ToPlayer(localSeat, _selfPlayer); + + private GameObject CreateBox( + string name, + Vector3 position, + Vector3 scale, + Color color) + { + return CreateBox(name, position, scale, BoardMaterials.CreateLit(color)); + } + + private GameObject CreateBox( + string name, + Vector3 position, + Vector3 scale, + Material material) + { + var box = GameObject.CreatePrimitive(PrimitiveType.Cube); + box.name = name; + box.transform.SetParent(transform, false); + box.transform.localPosition = position; + box.transform.localScale = scale; + + var collider = box.GetComponent(); + if (collider != null) + { + if (Application.isPlaying) + Destroy(collider); + else + DestroyImmediate(collider); + } + + var renderer = box.GetComponent(); + renderer.sharedMaterial = material; + BoardMaterials.SetShadows(renderer, false); + return box; + } + + private void OnDestroy() + { + if (_instance == this) + _instance = null; + + if (_cameraRig != null && _ownsRig) + { + if (Application.isPlaying) + Destroy(_cameraRig); + else + DestroyImmediate(_cameraRig); + } + } + + private sealed class CardSpec + { + public string Key; + public BoardCardRef Ref; + public BoardArt Art; + public Vector3 Position; + public Quaternion BaseRotation; + public bool FaceUp; + public bool Rested; + public Color? Highlight; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs.meta new file mode 100644 index 0000000..2dd14c9 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 178189a769df10042b9a76f8a2b7bb84 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs new file mode 100644 index 0000000..8be69f6 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs @@ -0,0 +1,146 @@ +using UnityEngine; + +namespace Vanguard.Board3D +{ + /// Колода, дроп или душа: стопка карт с подписью и коллайдером. + 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(); + 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(); + 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(); + topFilter.sharedMesh = BoardMeshLibrary.CardFace(width, height, front: true); + _top = topGo.AddComponent(); + _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(); + 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(); + if (collider == null) + return; + + if (Application.isPlaying) + Destroy(collider); + else + DestroyImmediate(collider); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs.meta new file mode 100644 index 0000000..9b046b2 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2cbf86187ded1564ea87365f921f8be5 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index 5f52186..2502b9f 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -8,6 +8,7 @@ using UnityEngine.UI; using Vanguard.Api; using Vanguard.Api.Contracts; using Vanguard.Battle; +using Vanguard.Board3D; using Vanguard.Meta; using Vanguard.Shared.Protocol; @@ -32,7 +33,6 @@ namespace Vanguard.UI private bool _catalogLoading; private readonly RectTransform _root; - private readonly RectTransform _board; private RectTransform _intents; private readonly Text _topInfo; private readonly Text _clock; @@ -40,6 +40,8 @@ namespace Vanguard.UI private readonly Button _concede; private readonly Text _concedeLabel; private readonly Button _reconnect; + private Button _soulCounter; + private Text _soulCounterLabel; private readonly RectTransform _result; private RectTransform _logContent; private Text _resultText; @@ -57,7 +59,6 @@ namespace Vanguard.UI private CancellationTokenSource _cts; private MatchConnectionInfo _info; private MatchView _view; - private MatchView _previousView; private bool _connecting; private bool _sending; private bool _snapshotPending; @@ -68,24 +69,17 @@ namespace Vanguard.UI private long _clockServerMs; private long _clockReceivedMs; private readonly List _pendingIntents = new List(); - private readonly HashSet _highlightKeys = new HashSet(StringComparer.Ordinal); private int _pendingBoosterSlot = -1; private int? _triggerPowerSlot; private readonly HashSet _mulliganSelected = new HashSet(); private bool _mulliganUsed; private int _abilityDamageIndex = -1; - private readonly Dictionary _cardPanels = - new Dictionary(StringComparer.Ordinal); - private readonly List _cardAnimations = new List(); - private readonly List _pendingEntries = new List(); - private readonly List _ghostAnimations = new List(); - private readonly Dictionary _cardPrintings = - new Dictionary(StringComparer.Ordinal); - private readonly Dictionary _cardFaceUp = - new Dictionary(StringComparer.Ordinal); - private readonly Dictionary _cardAnchors = - new Dictionary(StringComparer.Ordinal); - private RectTransform _fxLayer; + + private readonly HashSet _highlightSlots = new HashSet(); + private GameBoard3D _board3D; + private RectTransform _soulOverlay; + private RectTransform _soulContent; + private Text _soulTitle; public BattleView(Transform parent, MetaService meta, Action onExit) { @@ -93,6 +87,12 @@ namespace Vanguard.UI _onExit = onExit; _root = UiKit.CreatePanel(parent, "BattleScreen", Background); + var rootImage = _root.GetComponent(); + if (rootImage != null) + { + rootImage.color = Color.clear; + rootImage.raycastTarget = false; + } var top = UiKit.CreatePanel(_root, "TopBar", Panel); top.anchorMin = new Vector2(0f, 1f); @@ -111,6 +111,12 @@ namespace Vanguard.UI UiKit.MakeLiteral(_topInfo); UiKit.SetPreferredHeight(_topInfo, 38f); + _soulCounter = UiKit.CreateButton( + top, "SoulCounter", "Душа: 0", OnSoulCounterClicked, 16); + _soulCounterLabel = _soulCounter.GetComponentInChildren(); + var soulElement = UiKit.SetPreferredHeight(_soulCounter, 44f, 0f); + soulElement.preferredWidth = 132f; + _clock = UiKit.CreateText( top, "Clock", "—", 22, Color.white, TextAnchor.MiddleRight); UiKit.SetPreferredHeight(_clock, 44f, 0f); @@ -119,14 +125,6 @@ namespace Vanguard.UI _concedeLabel = _concede.GetComponentInChildren(); UiKit.SetPreferredHeight(_concede, 44f, 0f); - var left = UiKit.CreatePanel(_root, "BoardPanel", Background); - left.anchorMin = Vector2.zero; - left.anchorMax = Vector2.one; - left.offsetMin = new Vector2(36f, 28f); - left.offsetMax = new Vector2(-564f, -108f); - UiKit.AddVerticalLayout(left, 8, 8); - _board = left; - var right = UiKit.CreatePanel(_root, "IntentPanel", Panel); right.anchorMin = new Vector2(1f, 0f); right.anchorMax = new Vector2(1f, 1f); @@ -172,9 +170,6 @@ namespace Vanguard.UI _result = BuildResultPanel(_root); _result.gameObject.SetActive(false); - _fxLayer = UiKit.CreatePanel(_root, "BattleFx", Color.clear); - _fxLayer.GetComponent().raycastTarget = false; - _flash = UiKit.CreatePanel(_root, "BattleFlash", Color.clear).GetComponent(); _flash.raycastTarget = false; _flash.gameObject.SetActive(false); @@ -192,6 +187,16 @@ namespace Vanguard.UI feedbackRect.anchoredPosition = new Vector2(0f, 130f); _feedback.gameObject.SetActive(false); + BuildSoulOverlay(); + + _board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default); + _board3D.ArtProvider = ResolveBoardArt; + _board3D.CardClicked += OnBoard3DCardClicked; + _board3D.SlotClicked += OnBoard3DSlotClicked; + _board3D.CardDropped += OnBoard3DCardDropped; + _board3D.PileClicked += OnBoard3DPileClicked; + _board3D.SetVisible(false); + _root.gameObject.SetActive(false); } @@ -199,7 +204,6 @@ namespace Vanguard.UI { _info = info; _view = null; - _previousView = null; _finished = false; _confirmConcede = false; _concedeLabel.text = "Сдаться"; @@ -207,7 +211,7 @@ namespace Vanguard.UI _reconnect.gameObject.SetActive(false); _result.gameObject.SetActive(false); _pendingIntents.Clear(); - _highlightKeys.Clear(); + _highlightSlots.Clear(); _mulliganSelected.Clear(); _mulliganUsed = false; _abilityDamageIndex = -1; @@ -222,9 +226,14 @@ namespace Vanguard.UI _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); - ClearGhosts(); - _cardAnchors.Clear(); + _highlightSlots.Clear(); + _board3D?.Clear(); + _board3D?.SetVisible(true); + if (_soulOverlay != null) + _soulOverlay.gameObject.SetActive(false); _topInfo.text = (info.DeckName ?? string.Empty) + " · " + info.MatchId; + _soulCounterLabel.text = "Душа: 0"; + _soulCounter.interactable = false; SetStatus("Подключаемся к матчу…", Muted); _root.gameObject.SetActive(true); _ = LoadCatalogAsync(); @@ -249,6 +258,9 @@ namespace Vanguard.UI _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); + if (_soulOverlay != null) + _soulOverlay.gameObject.SetActive(false); + _board3D?.SetVisible(false); _view = null; } @@ -262,7 +274,6 @@ namespace Vanguard.UI UpdateClock(); UpdateFeedback(); - UpdateCardAnimations(); } private void ShowFeedback(string text, Color color, float duration = 1.25f, bool flash = false) @@ -321,321 +332,6 @@ namespace Vanguard.UI StartNextFeedback(); } - private void UpdateCardAnimations() - { - for (var i = _cardAnimations.Count - 1; i >= 0; i--) - { - var animation = _cardAnimations[i]; - if (animation.Target == null) - { - _cardAnimations.RemoveAt(i); - continue; - } - - animation.Time += Time.unscaledDeltaTime; - var t = Mathf.Clamp01(animation.Time / animation.Duration); - var eased = 1f - Mathf.Pow(1f - t, 3f); - var scale = Mathf.Lerp(animation.StartScale, 1f, eased); - animation.Target.localScale = new Vector3(scale, scale, 1f); - - if (animation.Move) - { - animation.Target.anchoredPosition = Vector2.Lerp( - animation.StartPosition, - animation.Destination, - eased); - } - - if (t >= 1f) - { - animation.Target.localScale = Vector3.one; - if (animation.Move) - animation.Target.anchoredPosition = animation.Destination; - _cardAnimations.RemoveAt(i); - } - } - - UpdateGhostAnimations(); - } - - private void UpdateGhostAnimations() - { - for (var i = _ghostAnimations.Count - 1; i >= 0; i--) - { - var ghost = _ghostAnimations[i]; - if (ghost.Target == null) - { - _ghostAnimations.RemoveAt(i); - continue; - } - - ghost.Time += Time.unscaledDeltaTime; - var t = Mathf.Clamp01(ghost.Time / ghost.Duration); - var eased = 1f - Mathf.Pow(1f - t, 2f); - ghost.Target.position = Vector3.Lerp(ghost.Start, ghost.End, eased); - ghost.Target.localScale = Vector3.one * Mathf.Lerp(1f, 0.82f, eased); - if (ghost.Group != null) - ghost.Group.alpha = 1f - eased; - - if (t < 1f) - continue; - - if (ghost.Target != null) - UnityEngine.Object.Destroy(ghost.Target.gameObject); - _ghostAnimations.RemoveAt(i); - } - } - - private void ClearGhosts() - { - foreach (var ghost in _ghostAnimations) - { - if (ghost.Target != null) - UnityEngine.Object.Destroy(ghost.Target.gameObject); - } - - _ghostAnimations.Clear(); - } - - private void CaptureCardAnchors() - { - foreach (var pair in _cardPrintings) - { - if (!_cardPanels.TryGetValue(pair.Key, out var panel) || panel == null) - continue; - - _cardFaceUp.TryGetValue(pair.Key, out var faceUp); - _cardAnchors[pair.Key] = new CardAnchor( - panel.position, - panel.rect.size, - pair.Value, - faceUp); - } - } - - private void SpawnRetireGhosts(Dictionary oldAnchors) - { - if (oldAnchors.Count == 0 || _fxLayer == null) - return; - - foreach (var pair in oldAnchors) - { - if (_cardAnchors.ContainsKey(pair.Key)) - continue; - - // Если карта просто переехала в другой слот, не считаем это уходом в дроп. - if (_cardAnchors.Values.Any(item => item.PrintingId == pair.Value.PrintingId)) - continue; - - CreateRetireGhost(pair.Key, pair.Value); - } - } - - private void CreateRetireGhost(string key, CardAnchor anchor) - { - var panel = UiKit.CreatePanel(_fxLayer, "Retired_" + key, Color.white); - panel.anchorMin = new Vector2(0.5f, 0.5f); - panel.anchorMax = new Vector2(0.5f, 0.5f); - panel.pivot = new Vector2(0.5f, 0.5f); - panel.sizeDelta = anchor.Size; - panel.position = anchor.World; - - var image = panel.GetComponent(); - image.raycastTarget = false; - var meta = _catalog.TryGetValue(anchor.PrintingId, out var found) ? found : null; - if (anchor.FaceUp) - { - CardImageService.Apply( - image, - meta != null ? meta.ImageKey : null, - CardArt.ForCard( - meta != null ? meta.NameId : anchor.PrintingId, - meta != null ? meta.Clan : "None")); - } - else - { - image.sprite = CardArt.Back; - image.color = Color.white; - } - - var outline = panel.gameObject.AddComponent(); - outline.effectColor = new Color(1f, 0.35f, 0.28f, 0.9f); - outline.effectDistance = new Vector2(2f, -2f); - - var group = panel.gameObject.AddComponent(); - group.blocksRaycasts = false; - group.interactable = false; - - var player = 0; - var split = key.IndexOf(':'); - if (split > 0) - int.TryParse(key.Substring(0, split), out player); - - var direction = _view != null && player == _view.You ? -1f : 1f; - _ghostAnimations.Add(new GhostAnimation( - panel, - group, - anchor.World, - anchor.World + new Vector3(0f, direction * 190f, 0f), - 0.36f)); - } - - private void FinalizeCardEntries() - { - // Сначала даём layout-группам посчитать конечные позиции карт. - Canvas.ForceUpdateCanvases(); - LayoutRebuilder.ForceRebuildLayoutImmediate(_board); - CaptureCardAnchors(); - - if (_pendingEntries.Count == 0) - return; - - foreach (var entry in _pendingEntries) - { - if (entry.Target == null) - continue; - - var destination = entry.Target.anchoredPosition; - var start = destination + entry.Offset; - entry.Target.anchoredPosition = start; - _cardAnimations.Add(new CardAnimation( - entry.Target, - 0.24f, - entry.StartScale, - start, - destination, - move: true)); - } - - _pendingEntries.Clear(); - } - - private void AnimateCardIn(RectTransform target, Vector2 offset) - { - if (target == null) - return; - - target.localScale = Vector3.one * 0.86f; - _pendingEntries.Add(new CardEntry(target, offset, 0.86f)); - } - - private void PulseCard(int player, int slot, float startScale) - { - if (!_cardPanels.TryGetValue(CardKey(player, slot), out var target) || target == null) - return; - - target.localScale = Vector3.one * startScale; - _cardAnimations.Add(new CardAnimation( - target, 0.28f, startScale, target.anchoredPosition, target.anchoredPosition, move: false)); - } - - private bool ShouldAnimateCard(int player, int slot, string printingId) - { - if (_previousView == null) - return true; - - var side = _previousView.Players.FirstOrDefault(item => item.Index == player); - if (side == null) - return true; - - if (slot == -2) - { - return side.Hand == null - || side.Hand.All(item => item.PrintingId != printingId); - } - - if (slot == -1) - return side.Vanguard == null || side.Vanguard.PrintingId != printingId; - - return side.RearGuards == null - || slot >= side.RearGuards.Length - || side.RearGuards[slot] == null - || side.RearGuards[slot].PrintingId != printingId; - } - - private static string CardKey(int player, int slot) => player + ":" + slot; - - private readonly struct CardAnchor - { - public CardAnchor(Vector3 world, Vector2 size, string printingId, bool faceUp) - { - World = world; - Size = size; - PrintingId = printingId; - FaceUp = faceUp; - } - - public Vector3 World { get; } - public Vector2 Size { get; } - public string PrintingId { get; } - public bool FaceUp { get; } - } - - private sealed class GhostAnimation - { - public GhostAnimation( - RectTransform target, - CanvasGroup group, - Vector3 start, - Vector3 end, - float duration) - { - Target = target; - Group = group; - Start = start; - End = end; - Duration = duration; - } - - public RectTransform Target { get; } - public CanvasGroup Group { get; } - public Vector3 Start { get; } - public Vector3 End { get; } - public float Duration { get; } - public float Time { get; set; } - } - - private sealed class CardEntry - { - public CardEntry(RectTransform target, Vector2 offset, float startScale) - { - Target = target; - Offset = offset; - StartScale = startScale; - } - - public RectTransform Target { get; } - public Vector2 Offset { get; } - public float StartScale { get; } - } - - private sealed class CardAnimation - { - public CardAnimation( - RectTransform target, - float duration, - float startScale, - Vector2 startPosition, - Vector2 destination, - bool move) - { - Target = target; - Duration = duration; - StartScale = startScale; - StartPosition = startPosition; - Destination = destination; - Move = move; - } - - public RectTransform Target { get; } - public float Duration { get; } - public float StartScale { get; } - public Vector2 StartPosition { get; } - public Vector2 Destination { get; } - public bool Move { get; } - public float Time { get; set; } - } - private void SkipFeedback() { _feedbackQueue.Clear(); @@ -772,11 +468,9 @@ namespace Vanguard.UI if (!_root.gameObject.activeSelf || snapshot?.View == null) return; - _previousView = _view; _view = snapshot.View; _finished = _view.IsFinished; _pendingIntents.Clear(); - _highlightKeys.Clear(); _triggerPowerSlot = null; PrepareTriggerSelection(); if (_view.Clock != null) @@ -977,7 +671,7 @@ namespace Vanguard.UI _abilityDamageIndex = -1; _pendingIntents.Clear(); - _highlightKeys.Clear(); + _highlightSlots.Clear(); _pendingBoosterSlot = -1; _triggerPowerSlot = null; _sending = true; @@ -992,6 +686,8 @@ namespace Vanguard.UI SetStatus( "Отклонено (" + result.Code + "): " + (result.Reason ?? "без причины"), new Color(1f, 0.55f, 0.45f)); + if (_view != null) + Render(); } else { @@ -1074,32 +770,9 @@ namespace Vanguard.UI if (_view == null) return; - var oldAnchors = new Dictionary(_cardAnchors, StringComparer.Ordinal); - ClearChildren(_board); - _cardPanels.Clear(); - _cardPrintings.Clear(); - _cardFaceUp.Clear(); - _cardAnchors.Clear(); - _cardAnimations.Clear(); - _pendingEntries.Clear(); - var opponentIndex = _view.You == 0 ? 1 : 0; - var opponent = _view.Players.FirstOrDefault(player => player.Index == opponentIndex) - ?? (_view.Players.Count > opponentIndex ? _view.Players[opponentIndex] : null); - var self = _view.Players.FirstOrDefault(player => player.Index == _view.You) - ?? (_view.Players.Count > _view.You ? _view.Players[_view.You] : null); - - AddSideField(opponent, false); - AddGuardZone(_view); - AddSideField(self, true); - AddHand(self); - FinalizeCardEntries(); - SpawnRetireGhosts(oldAnchors); - - if (_view.Battle != null) - { - PulseCard(_view.Battle.AttackerPlayer, _view.Battle.AttackerSlot, 1.11f); - PulseCard(1 - _view.Battle.AttackerPlayer, _view.Battle.TargetSlot, 1.11f); - } + _board3D?.SetSelfPlayer(_view.You); + _board3D?.Render(_view, BuildBoardRenderState()); + UpdateSoulCounter(); RebuildIntents(_view); UpdateClock(); @@ -1108,197 +781,80 @@ namespace Vanguard.UI ShowResult(_view); } - private void AddSideField(PlayerView player, bool self) + private BoardRenderState BuildBoardRenderState() { - if (player == null) - return; - - var block = UiKit.CreatePanel( - _board, - self ? "SelfField" : "OpponentField", - new Color(0.05f, 0.07f, 0.11f, 0.96f)); - var blockElement = block.gameObject.AddComponent(); - blockElement.preferredHeight = 334f; - UiKit.AddVerticalLayout(block, 5, 3); - - var header = UiKit.CreateText( - block, - "Header", - (self ? "ВЫ" : "ПРОТИВНИК") + " · " + player.Name - + " рука " + player.HandCount - + " · колода " + player.DeckCount - + " · урон " + (player.FaceDownDamage + player.FaceUpDamage.Count) - + " · soul " + player.Soul.Count - + " · drop " + player.Drop.Count, - 15, - self ? Color.white : new Color(0.94f, 0.82f, 0.70f), - TextAnchor.MiddleLeft); - header.fontStyle = FontStyle.Bold; - UiKit.SetPreferredHeight(header, 24f); - - var body = UiKit.CreatePanel(block, "Body", new Color(0f, 0f, 0f, 0f)); - var bodyElement = body.gameObject.AddComponent(); - bodyElement.flexibleHeight = 1f; - bodyElement.minHeight = 294f; - UiKit.AddHorizontalLayout(body, 0, 8); - - if (self) + var state = new BoardRenderState { - AddDamageColumn(body, player); - AddFieldGrid(body, player, self); - AddPileColumn(body, player); - } - else + MulliganSelecting = IsMulliganSelecting(), + DamageChoice = IsDamageChoice(), + AbilityDamageTargeting = IsAbilityDamageTargeting(), + AbilityDamageIndex = _abilityDamageIndex, + MatchFinished = _view != null && _view.IsFinished, + }; + + foreach (var key in _highlightSlots) + state.Highlights.Add(key); + + if (_view != null) { - AddPileColumn(body, player); - AddFieldGrid(body, player, self); - AddDamageColumn(body, player); + foreach (var intent in _view.LegalIntents ?? new List()) + { + switch (intent) + { + case CallIntent call: + state.DropTargets.Add(new BoardSlotKey(_view.You, call.Slot)); + break; + case RideIntent _: + state.DropTargets.Add(new BoardSlotKey( + _view.You, + PlaymatLayout.VanguardSlot)); + break; + case MoveIntent move: + state.DropTargets.Add(new BoardSlotKey(_view.You, move.ToSlot)); + break; + case AttackIntent attack: + state.DropTargets.Add(new BoardSlotKey( + 1 - _view.You, + attack.TargetSlot)); + break; + case GuardIntent _: + case InterceptIntent _: + state.GuardianDropAllowed = true; + break; + } + } } + + if (state.MulliganSelecting && _view != null && _mulliganSelected.Count > 0) + { + var hand = _view.Players + .FirstOrDefault(player => player.Index == _view.You) + ?.Hand; + if (hand != null) + { + foreach (var index in _mulliganSelected) + { + if (index >= 0 && index < hand.Count) + state.SelectedHandInstanceIds.Add(hand[index].InstanceId); + } + } + } + + return state; } - private void AddFieldGrid(Transform parent, PlayerView player, bool self) + private BoardArt ResolveBoardArt(string printingId) { - var grid = UiKit.CreatePanel(parent, "FieldGrid", new Color(0f, 0f, 0f, 0f)); - var gridElement = grid.gameObject.AddComponent(); - gridElement.flexibleWidth = 1f; - gridElement.flexibleHeight = 1f; - - var layout = grid.gameObject.AddComponent(); - layout.cellSize = new Vector2(140f, 140f); - layout.spacing = new Vector2(6f, 6f); - layout.padding = new RectOffset(4, 4, 4, 4); - layout.constraint = GridLayoutGroup.Constraint.FixedColumnCount; - layout.constraintCount = 3; - layout.childAlignment = TextAnchor.MiddleCenter; - layout.startCorner = GridLayoutGroup.Corner.UpperLeft; - layout.startAxis = GridLayoutGroup.Axis.Horizontal; - - // Первый ряд: R V R. Второй ряд: R R R. - // Для противника ряды зеркальны: его V-ряд ближе к центру. - var slots = self - ? new[] { 3, -1, 4, 0, 1, 2 } - : new[] { 0, 1, 2, 3, -1, 4 }; - - foreach (var slot in slots) - { - var card = CardAt(player.Index, slot); - AddCardBox( - grid, - card, - slot == -1 ? "V" : slot <= 2 ? "SUP" + (slot + 1) : "R" + (slot + 1), - compact: false, - player.Index, - slot); - } - } - - private void AddDamageColumn(Transform parent, PlayerView player) - { - var column = UiKit.CreatePanel(parent, "DamageColumn", new Color(0f, 0f, 0f, 0f)); - var element = column.gameObject.AddComponent(); - element.preferredWidth = 174f; - element.minWidth = 158f; - element.flexibleWidth = 0f; - UiKit.AddVerticalLayout(column, 0, 4); - - AddDamageZone(column, player); - AddPile(column, "SOUL", player.Soul.Count, - new Color(0.26f, 0.18f, 0.42f), 78f); - } - - private void AddDamageZone(Transform parent, PlayerView player) - { - var count = player.FaceDownDamage + player.FaceUpDamage.Count; - var zone = UiKit.CreatePanel( - parent, "DamageZone", new Color(0.16f, 0.05f, 0.06f, 0.9f)); - var element = zone.gameObject.AddComponent(); - element.preferredHeight = 206f; - element.flexibleWidth = 1f; - UiKit.AddVerticalLayout(zone, 4, 2); - - var title = UiKit.CreateText( - zone, "Title", "УРОН · " + count, 11, - new Color(1f, 0.78f, 0.72f), TextAnchor.MiddleCenter); - title.fontStyle = FontStyle.Bold; - UiKit.SetPreferredHeight(title, 18f); - - var grid = UiKit.CreatePanel(zone, "DamageCards", new Color(0f, 0f, 0f, 0f)); - var gridElement = grid.gameObject.AddComponent(); - gridElement.flexibleHeight = 1f; - gridElement.preferredHeight = 176f; - - var damageChoice = IsDamageChoice(); - var abilityTargeting = IsAbilityDamageTargeting(); - var displayIndex = 0; - - foreach (var card in player.FaceUpDamage) - { - AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting); - displayIndex++; - } - - for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++) - { - AddDamageCard(grid, displayIndex, null, false, damageChoice, abilityTargeting: false); - } - } - - private void AddDamageCard( - RectTransform parent, - int displayIndex, - CardView card, - bool faceUp, - bool damageChoice, - bool abilityTargeting) - { - var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white); - var rect = panel; - rect.anchorMin = new Vector2(0.5f, 0.5f); - rect.anchorMax = new Vector2(0.5f, 0.5f); - rect.pivot = new Vector2(0.5f, 0.5f); - rect.sizeDelta = new Vector2(58f, 78f); - rect.anchoredPosition = new Vector2( - displayIndex % 2 == 0 ? -42f : 42f, - 58f - (displayIndex / 2) * 58f); - rect.localRotation = Quaternion.Euler(0f, 0f, -90f); - - var image = panel.GetComponent(); - image.raycastTarget = true; - var meta = card != null && _catalog.TryGetValue(card.PrintingId, out var found) - ? found - : null; - if (faceUp) - { - CardImageService.Apply( - image, - meta != null ? meta.ImageKey : null, - CardArt.ForCard( - meta != null ? meta.NameId : card?.PrintingId ?? "damage", - meta != null ? meta.Clan : "None")); - } - else - { - image.sprite = CardArt.Back; - image.color = Color.white; - } - - var canClick = damageChoice || abilityTargeting; - var selected = abilityTargeting && _abilityDamageIndex == displayIndex; - var outline = panel.gameObject.AddComponent(); - outline.effectColor = selected - ? new Color(0.45f, 0.95f, 0.62f) - : canClick - ? new Color(0.98f, 0.82f, 0.42f) - : new Color(0.42f, 0.18f, 0.18f); - outline.effectDistance = canClick - ? new Vector2(selected ? 4f : 3f, selected ? -4f : -3f) - : new Vector2(1f, -1f); - - var button = panel.gameObject.AddComponent