diff --git a/client/Assets/Vanguard/Editor/SceneBuilder.cs b/client/Assets/Vanguard/Editor/SceneBuilder.cs index 9595282..ea0561a 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.Battle; using Vanguard.Board3D; namespace Vanguard.Editor @@ -20,6 +21,8 @@ namespace Vanguard.Editor private const string BootPath = "Assets/Scenes/Boot.unity"; private const string PlaymatConfigPath = "Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset"; + private const string AnimationConfigPath = + "Assets/Vanguard/Resources/AnimationConfig.asset"; private const string PlaymatScenePath = SceneDir + "/GameBoard3D.unity"; [MenuItem("Vanguard/Rebuild Client Scenes")] @@ -36,6 +39,7 @@ namespace Vanguard.Editor Create("DeckBuilder"); Create("Battle"); EnsurePlaymatConfig(); + EnsureAnimationConfig(); var scenes = new List(); Add(scenes, BootPath); @@ -92,6 +96,35 @@ namespace Vanguard.Editor Debug.Log("Vanguard playmat sandbox rebuilt: " + PlaymatScenePath); } + [MenuItem("Vanguard/Rebuild Animation Config")] + public static void BuildAnimationConfig() + { + EnsureAnimationConfig(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Debug.Log("Vanguard animation config ready: " + AnimationConfigPath); + } + + private static AnimationConfig EnsureAnimationConfig() + { + var config = AssetDatabase.LoadAssetAtPath(AnimationConfigPath); + if (config != null) + return config; + + var directory = Path.GetDirectoryName(AnimationConfigPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + AssetDatabase.Refresh(); + } + + config = ScriptableObject.CreateInstance(); + config.name = "AnimationConfig"; + AssetDatabase.CreateAsset(config, AnimationConfigPath); + AssetDatabase.SaveAssets(); + return config; + } + private static PlaymatLayoutConfig EnsurePlaymatConfig() { var config = AssetDatabase.LoadAssetAtPath(PlaymatConfigPath); diff --git a/client/Assets/Vanguard/Resources/AnimationConfig.asset b/client/Assets/Vanguard/Resources/AnimationConfig.asset new file mode 100644 index 0000000..cf04947 --- /dev/null +++ b/client/Assets/Vanguard/Resources/AnimationConfig.asset @@ -0,0 +1,32 @@ +%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: 0636c0f7bf02c0548adae5e43ddb8f26, type: 3} + m_Name: AnimationConfig + m_EditorClassIdentifier: Vanguard.Runtime::Vanguard.Battle.AnimationConfig + cardMoveSpeed: 14 + cardRotateSpeed: 12 + cardMoveDuration: 0.45 + cardFlipDuration: 0.3 + callMoveDuration: 0.55 + retireDuration: 0.8 + attackDeclare: 1.2 + guard: 1.9 + driveCheck: 1.5 + trigger: 1.4 + battleResolved: 1.3 + damage: 1.6 + heal: 1.4 + intercept: 1.9 + perfectGuard: 2 + inputLockTail: 0.15 + arrowShowDuration: 0.25 + arrowHideDuration: 0.2 diff --git a/client/Assets/Vanguard/Resources/AnimationConfig.asset.meta b/client/Assets/Vanguard/Resources/AnimationConfig.asset.meta new file mode 100644 index 0000000..a5cb3ef --- /dev/null +++ b/client/Assets/Vanguard/Resources/AnimationConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 233e60b202fccbf4d868f32ee2a54777 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs new file mode 100644 index 0000000..1112fb2 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs @@ -0,0 +1,83 @@ +using UnityEngine; + +namespace Vanguard.Battle +{ + /// + /// Длительности боевых анимаций и блокировки ввода. Ассет лежит в + /// Resources/AnimationConfig; если его нет, используются значения по умолчанию. + /// + [CreateAssetMenu(menuName = "Vanguard/Animation Config", fileName = "AnimationConfig")] + public sealed class AnimationConfig : ScriptableObject + { + [Header("Карты")] + public float cardMoveSpeed = 14f; + public float cardRotateSpeed = 12f; + public float cardMoveDuration = 0.45f; + public float cardFlipDuration = 0.3f; + public float callMoveDuration = 0.55f; + public float retireDuration = 0.8f; + + [Header("Бой")] + public float attackDeclare = 1.2f; + public float guard = 1.9f; + public float driveCheck = 1.5f; + public float trigger = 1.4f; + public float battleResolved = 1.3f; + public float damage = 1.6f; + public float heal = 1.4f; + public float intercept = 1.9f; + public float perfectGuard = 2.0f; + + [Header("Ввод")] + public float inputLockTail = 0.15f; + + [Header("Стрелка атаки")] + public float arrowShowDuration = 0.25f; + public float arrowHideDuration = 0.2f; + + private static AnimationConfig _default; + + public static AnimationConfig Default + { + get + { + if (_default == null) + { + _default = CreateInstance(); + _default.name = "AnimationConfig (runtime)"; + _default.hideFlags = HideFlags.DontSave; + } + + return _default; + } + } + + public static AnimationConfig LoadOrDefault() + { + var loaded = Resources.Load("AnimationConfig"); + return loaded != null ? loaded : Default; + } + + private void OnValidate() + { + cardMoveSpeed = Mathf.Max(1f, cardMoveSpeed); + cardRotateSpeed = Mathf.Max(1f, cardRotateSpeed); + cardMoveDuration = Mathf.Max(0.05f, cardMoveDuration); + cardFlipDuration = Mathf.Max(0.05f, cardFlipDuration); + callMoveDuration = Mathf.Max(0.05f, callMoveDuration); + retireDuration = Mathf.Max(0.05f, retireDuration); + attackDeclare = Mathf.Max(0.05f, attackDeclare); + guard = Mathf.Max(0.05f, guard); + driveCheck = Mathf.Max(0.05f, driveCheck); + trigger = Mathf.Max(0.05f, trigger); + battleResolved = Mathf.Max(0.05f, battleResolved); + damage = Mathf.Max(0.05f, damage); + heal = Mathf.Max(0.05f, heal); + intercept = Mathf.Max(0.05f, intercept); + perfectGuard = Mathf.Max(0.05f, perfectGuard); + inputLockTail = Mathf.Max(0f, inputLockTail); + arrowShowDuration = Mathf.Max(0.05f, arrowShowDuration); + arrowHideDuration = Mathf.Max(0.05f, arrowHideDuration); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs.meta b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs.meta new file mode 100644 index 0000000..1081f09 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0636c0f7bf02c0548adae5e43ddb8f26 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Battle/InputLockService.cs b/client/Assets/Vanguard/Runtime/Battle/InputLockService.cs new file mode 100644 index 0000000..003e34b --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/InputLockService.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Vanguard.Battle +{ + /// + /// Счётчик причин блокировки: анимация, оверлей, контекстное меню. + /// Ввод возвращается только когда снята последняя причина. + /// + public sealed class InputLockService + { + private readonly Dictionary _reasons = + new Dictionary(StringComparer.Ordinal); + + public bool IsLocked => _reasons.Count > 0; + + public event Action Changed; + + public void Acquire(string reason) + { + if (string.IsNullOrEmpty(reason)) + return; + + var wasLocked = _reasons.Count > 0; + _reasons.TryGetValue(reason, out var count); + _reasons[reason] = count + 1; + + if (!wasLocked) + Changed?.Invoke(true); + } + + public void Release(string reason) + { + if (string.IsNullOrEmpty(reason)) + return; + + if (!_reasons.TryGetValue(reason, out var count)) + return; + + if (count > 1) + { + _reasons[reason] = count - 1; + return; + } + + _reasons.Remove(reason); + if (_reasons.Count == 0) + Changed?.Invoke(false); + } + + public void ReleaseAll() + { + if (_reasons.Count == 0) + return; + + _reasons.Clear(); + Changed?.Invoke(false); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Battle/InputLockService.cs.meta b/client/Assets/Vanguard/Runtime/Battle/InputLockService.cs.meta new file mode 100644 index 0000000..6d75bfe --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/InputLockService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 74c6d538edc00d040ac6486bfa5513ce \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset index d98f33d..63e2954 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset @@ -51,10 +51,11 @@ MonoBehaviour: opponentHandScale: 0.8 opponentHandRoll: 2.5 guardianRowGap: 0.55 - guardianSpacing: 0.44 + guardianSpacing: 0.62 guardianMaxWidth: 3.6 damageX: 3 pileX: 2.95 + showZoneLabels: 0 cameraPosition: {x: 1.4, y: 5.8, z: -8} cameraTarget: {x: 1.4, y: 0, z: 0} cameraFov: 46 diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs index 4867e3e..dd727d6 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs @@ -72,7 +72,7 @@ namespace Vanguard.Board3D [Header("Круг защиты")] public float guardianRowGap = 0.55f; - public float guardianSpacing = 0.44f; + public float guardianSpacing = 0.62f; public float guardianMaxWidth = 3.6f; [Header("Стопки")] @@ -81,6 +81,10 @@ namespace Vanguard.Board3D [Tooltip("Расстояние от центра стола до колоды и дропа.")] public float pileX = 2.95f; + [Header("Отладка")] + [Tooltip("Показывать подписи зон на 3D-столе.")] + public bool showZoneLabels = false; + [Header("Камера")] [Tooltip("Фиксированная камера над полем. Не двигается игроком.")] public Vector3 cameraPosition = new Vector3(1.4f, 5.8f, -8.0f); diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs index a4a2a43..70a40db 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs @@ -92,9 +92,10 @@ namespace Vanguard.Board3D { var x = (localSeat == 0 ? 1f : -1f) * _config.pileX; var offset = index - (Mathf.Max(1, count) - 1) * 0.5f; + var sideSign = localSeat == 0 ? 1f : -1f; // «Над колодой» — дальше от камеры, то есть выше на экране. var z = (ZSign(localSeat) * _config.frontRowZ) + 1.0f; - return new Vector3(x + offset * 0.42f, _config.cardLift, z); + return new Vector3(x + offset * 0.42f * sideSign, _config.cardLift, z); } /// Душа — визуальная стопка под кругом V, чуть ближе к игроку. @@ -113,7 +114,11 @@ namespace Vanguard.Board3D var offset = index - (count - 1) * 0.5f; var z = ZSign(localSeat) * _config.guardianRowGap; - return new Vector3(offset * spacing, _config.GuardianCenterY, z); + var stackLift = index * 0.0015f; + return new Vector3( + offset * spacing, + _config.GuardianCenterY + stackLift, + z); } /// Веер руки перед локальным игроком. diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs b/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs new file mode 100644 index 0000000..ede4cbd --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs @@ -0,0 +1,185 @@ +using UnityEngine; +using Vanguard.Battle; + +namespace Vanguard.Board3D +{ + /// + /// Стрелка атаки на столе: линия от атакующего к цели и наконечник. + /// Точки обновляются каждый кадр, поэтому стрелка едет за картами. + /// + public sealed class AttackArrowView + { + private const float ShaftWidth = 0.075f; + private const float HeadWidth = 0.17f; + private const float HeadLength = 0.38f; + + private GameObject _root; + private LineRenderer _shaft; + private LineRenderer _head; + private Material _material; + private AnimationConfig _config; + private Vector3 _from; + private Vector3 _to; + private bool _visible; + private float _alpha; + private float _widthScale = 1f; + + public bool IsVisible => _visible || _alpha > 0.001f; + + public void Build(Transform parent, AnimationConfig config) + { + _config = config; + _root = new GameObject("Attack Arrow"); + _root.transform.SetParent(parent, false); + + var shader = Shader.Find("Sprites/Default"); + if (shader == null) + shader = Shader.Find("Unlit/Color"); + + _material = shader != null ? new Material(shader) : null; + _shaft = CreateLine("Shaft", ShaftWidth); + _head = CreateLine("Head", ShaftWidth * 0.7f); + _head.positionCount = 3; + _root.SetActive(false); + } + + public void SetConfig(AnimationConfig config) + { + _config = config; + } + + public void Show(Vector3 from, Vector3 to) + { + SetPoints(from, to); + _visible = true; + + if (_root != null) + _root.SetActive(true); + } + + public void Hide() + { + _visible = false; + } + + public void HideImmediate() + { + _visible = false; + _alpha = 0f; + if (_root != null) + _root.SetActive(false); + } + + public void SetPoints(Vector3 from, Vector3 to) + { + _from = from; + _to = to; + UpdateGeometry(); + } + + public void Tick(float deltaTime) + { + if (_root == null) + return; + + var target = _visible ? 1f : 0f; + var duration = _visible + ? (_config != null ? _config.arrowShowDuration : 0.25f) + : (_config != null ? _config.arrowHideDuration : 0.2f); + _alpha = Mathf.MoveTowards( + _alpha, + target, + deltaTime / Mathf.Max(0.01f, duration)); + + if (!_visible && _alpha <= 0.001f) + { + _root.SetActive(false); + return; + } + + _root.SetActive(true); + _widthScale = 0.55f + (_alpha * 0.45f); + UpdateGeometry(); + ApplyColor(); + } + + public void Dispose() + { + if (_root != null) + { + if (Application.isPlaying) + Object.Destroy(_root); + else + Object.DestroyImmediate(_root); + _root = null; + } + + if (_material != null) + { + if (Application.isPlaying) + Object.Destroy(_material); + else + Object.DestroyImmediate(_material); + _material = null; + } + } + + private LineRenderer CreateLine(string name, float width) + { + var go = new GameObject(name); + go.transform.SetParent(_root.transform, false); + var line = go.AddComponent(); + line.useWorldSpace = false; + line.positionCount = 2; + line.widthMultiplier = width; + line.numCapVertices = 4; + line.numCornerVertices = 4; + line.alignment = LineAlignment.View; + line.textureMode = LineTextureMode.Stretch; + line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + line.receiveShadows = false; + if (_material != null) + line.material = _material; + return line; + } + + private void UpdateGeometry() + { + if (_shaft == null || _head == null) + return; + + var direction = _to - _from; + if (direction.sqrMagnitude < 0.0001f) + direction = Vector3.forward; + direction.Normalize(); + + _shaft.SetPosition(0, _from); + _shaft.SetPosition(1, _to); + _shaft.widthMultiplier = ShaftWidth * _widthScale; + + var tip = _to; + var back = _to - (direction * HeadLength); + var side = Vector3.Cross(Vector3.up, direction).normalized; + if (side.sqrMagnitude < 0.0001f) + side = Vector3.right; + var half = side * (HeadWidth * _widthScale); + _head.SetPosition(0, back + half); + _head.SetPosition(1, tip); + _head.SetPosition(2, back - half); + _head.widthMultiplier = ShaftWidth * 0.7f * _widthScale; + } + + private void ApplyColor() + { + var color = new Color(1f, 0.72f, 0.26f, _alpha * 0.92f); + foreach (var line in new[] { _shaft, _head }) + { + if (line == null) + continue; + + line.startColor = color; + line.endColor = color; + } + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs.meta new file mode 100644 index 0000000..64f859b --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f6b37f3c1f69dcb419ed41db1abe16fc \ 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 index a950bc5..6c25e65 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs @@ -29,6 +29,8 @@ namespace Vanguard.Board3D private bool _dragging; private Vector3 _dragOriginPosition; private Quaternion _dragOriginRotation; + private float _moveSpeed = 16f; + private float _rotateSpeed = 14f; public BoardCardRef Ref { get; private set; } public bool IsFaceUp => _faceUp; @@ -37,6 +39,11 @@ namespace Vanguard.Board3D public Vector3 TargetPosition => _targetPosition; public float TargetYaw => _targetRotation.eulerAngles.y; public Quaternion TargetRotation => _targetRotation; + public bool IsAnimating => + !_dragging + && (Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f + || Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f + || Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f); public string StatsText => _stats != null && _stats.gameObject.activeSelf ? _stats.text : null; @@ -53,6 +60,8 @@ namespace Vanguard.Board3D private void Build(PlaymatLayoutConfig config) { _config = config; + _moveSpeed = config.smoothMoveSpeed; + _rotateSpeed = config.smoothRotateSpeed; _block = new MaterialPropertyBlock(); var width = _config.cardWidth; @@ -138,8 +147,13 @@ namespace Vanguard.Board3D _front.SetPropertyBlock(_block); } - public void SetStats(string text, bool modified) + public void SetAnimationSpeeds(float moveSpeed, float rotateSpeed) { + _moveSpeed = Mathf.Max(1f, moveSpeed); + _rotateSpeed = Mathf.Max(1f, rotateSpeed); + } + + public void SetStats(string text, bool modified) { if (_stats == null) return; @@ -297,8 +311,8 @@ namespace Vanguard.Board3D return; } - var move = 1f - Mathf.Exp(-_config.smoothMoveSpeed * deltaTime); - var rotate = 1f - Mathf.Exp(-_config.smoothRotateSpeed * deltaTime); + var move = 1f - Mathf.Exp(-_moveSpeed * deltaTime); + var rotate = 1f - Mathf.Exp(-_rotateSpeed * 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); diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index 753cc46..ee9826d 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -4,6 +4,7 @@ using System.Linq; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.InputSystem; +using Vanguard.Battle; using Vanguard.Shared.Protocol; namespace Vanguard.Board3D @@ -44,9 +45,13 @@ namespace Vanguard.Board3D private Transform _pilesRoot; private Transform _zonesRoot; private Camera _camera; + private InputLockService _inputLock; + private AnimationConfig _animationConfig; + private float _boardLockReleaseAt = -1f; private BoardZoneView _guardianZone; private BoardZoneView _dropIndicator; + private AttackArrowView _attackArrow; private Pile3D _deckSelf; private Pile3D _deckOpponent; private Pile3D _dropSelf; @@ -72,8 +77,53 @@ namespace Vanguard.Board3D public PlaymatLayoutConfig Config => _config; public PlaymatLayout Layout => _layout; public int SelfPlayer => _selfPlayer; + public bool AttackArrowVisible => + _attackArrow != null && _attackArrow.IsVisible; public BoardArtProvider ArtProvider { get; set; } + public bool TryGetCardScreenPosition(BoardCardRef reference, out Vector2 screen) + { + screen = default; + if (_camera == null) + _camera = Camera.main; + if (_camera == null) + return false; + + foreach (var card in _cards.Values) + { + if (card == null || !card.Ref.Equals(reference)) + continue; + + var point = _camera.WorldToScreenPoint(card.transform.position); + if (point.z <= 0f) + return false; + + screen = new Vector2(point.x, point.y); + return true; + } + + return false; + } + + public void ConfigureRuntime( + AnimationConfig animationConfig, + InputLockService inputLock) + { + _animationConfig = animationConfig; + _inputLock = inputLock; + _attackArrow?.SetConfig(_animationConfig); + + if (_animationConfig == null) + return; + + foreach (var card in _cards.Values) + { + card.SetAnimationSpeeds( + _animationConfig.cardMoveSpeed, + _animationConfig.cardRotateSpeed); + } + } + public event Action CardClicked; public event Action CardRightClicked; public event Action SlotClicked; @@ -117,6 +167,7 @@ namespace Vanguard.Board3D BuildZones(); BuildPiles(); BuildDropIndicator(); + BuildAttackArrow(); _cardsRoot = new GameObject("Cards").transform; _cardsRoot.SetParent(transform, false); @@ -149,6 +200,8 @@ namespace Vanguard.Board3D CancelPointer(); _hasRendered = false; _handBindings.Clear(); + _attackArrow?.HideImmediate(); + ReleaseBoardLock(); } else { @@ -232,15 +285,115 @@ namespace Vanguard.Board3D UpdatePiles(view); UpdateSlots(state); + UpdateAttackArrowState(view); _hasRendered = true; - } + if (_inputLock != null + && _animationConfig != null + && AnyCardAnimating()) + { + _inputLock.Acquire("board"); + _boardLockReleaseAt = Time.unscaledTime + + Mathf.Max(1f, _animationConfig.cardMoveDuration * 3f) + + _animationConfig.inputLockTail; + } + } public void Clear() { ClearCards(); _handBindings.Clear(); _view = null; _hasRendered = false; + _attackArrow?.HideImmediate(); + ReleaseBoardLock(); + } + + private void UpdateAttackArrowState(MatchView view) + { + if (_attackArrow == null) + return; + + if (view?.Battle == null + || !TryResolveAttackPoints(view, out var from, out var to)) + { + _attackArrow.Hide(); + return; + } + + _attackArrow.Show(from, to); + } + + private void UpdateAttackArrowPoints() + { + if (_attackArrow == null || !_attackArrow.IsVisible || _view?.Battle == null) + return; + + if (TryResolveAttackPoints(_view, out var from, out var to)) + _attackArrow.SetPoints(from, to); + } + + private bool TryResolveAttackPoints( + MatchView view, + out Vector3 from, + out Vector3 to) + { + from = default; + to = default; + var battle = view?.Battle; + if (battle == null) + return false; + + var attacker = BattleCard(view, battle.AttackerPlayer, battle.AttackerSlot); + var defenderIndex = 1 - battle.AttackerPlayer; + var defender = BattleCard(view, defenderIndex, battle.TargetSlot); + if (attacker == null || defender == null) + return false; + + from = ArrowAnchor( + BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker), + battle.AttackerPlayer, + battle.AttackerSlot); + to = ArrowAnchor( + BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender), + defenderIndex, + battle.TargetSlot); + return true; + } + + private Vector3 ArrowAnchor(BoardCardRef reference, int player, int slot) + { + var card = FindCard(reference); + if (card != null) + return card.transform.localPosition + (Vector3.up * 0.2f); + + return _layout.CirclePositionForPlayer(player, _selfPlayer, slot) + + (Vector3.up * 0.2f); + } + + private CardView BattleCard(MatchView view, int playerIndex, int slot) + { + var player = view?.Players.FirstOrDefault(item => item.Index == playerIndex); + if (player == null) + return null; + + if (slot < 0) + return player.Vanguard; + + return player.RearGuards != null + && slot < player.RearGuards.Length + ? player.RearGuards[slot] + : null; + } + + private Card3D FindCard(BoardCardRef reference) + { + foreach (var card in _cards.Values) + { + if (card != null && card.Ref.Equals(reference)) + return card; + } + + return null; } private void BuildTable() @@ -376,7 +529,7 @@ namespace Vanguard.Board3D 1.05f, Mathf.Max(0.016f, _config.cardWidth * 0.025f), new Color(0.30f, 0.18f, 0.52f), - "TRIGGER", + _config.showZoneLabels ? "TRIGGER" : string.Empty, 0.04f, zSign * 0.72f, new Color(0.82f, 0.76f, 1f, 0.75f), @@ -389,10 +542,22 @@ namespace Vanguard.Board3D _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)); + _deckSelf = CreatePile( + BoardZone.Deck, 0, 0, + _config.showZoneLabels ? "DECK" : string.Empty, + new Color(0.20f, 0.34f, 0.60f)); + _deckOpponent = CreatePile( + BoardZone.Deck, 1, 1, + _config.showZoneLabels ? "DECK" : string.Empty, + new Color(0.60f, 0.26f, 0.26f)); + _dropSelf = CreatePile( + BoardZone.Drop, 0, 0, + _config.showZoneLabels ? "DROP" : string.Empty, + new Color(0.30f, 0.30f, 0.40f)); + _dropOpponent = CreatePile( + BoardZone.Drop, 1, 1, + _config.showZoneLabels ? "DROP" : string.Empty, + new Color(0.40f, 0.28f, 0.28f)); } private Pile3D CreatePile( @@ -439,6 +604,12 @@ namespace Vanguard.Board3D _dropIndicator.gameObject.SetActive(false); } + private void BuildAttackArrow() + { + _attackArrow = new AttackArrowView(); + _attackArrow.Build(transform, _animationConfig); + } + private void EnsureCamera() { if (_camera == null) @@ -540,7 +711,10 @@ namespace Vanguard.Board3D 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), + BaseRotation = Quaternion.Euler( + 0f, + _layout.BaseYaw(localSeat) + 90f, + 0f), FaceUp = true, Rested = false, }); @@ -558,7 +732,10 @@ namespace Vanguard.Board3D localSeat, guardians.Count + i, total), - BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f), + BaseRotation = Quaternion.Euler( + 0f, + _layout.BaseYaw(localSeat) + 90f, + 0f), FaceUp = true, Rested = false, }); @@ -617,8 +794,10 @@ namespace Vanguard.Board3D Ref = BoardCardRef.Trigger(player.Index, i, card), Art = ResolveArt(card.PrintingId), Position = _layout.TriggerPosition(localSeat, i, count), - // Карты проверки всегда развёрнуты к локальному игроку. - BaseRotation = Quaternion.identity, + BaseRotation = Quaternion.Euler( + 0f, + _layout.BaseYaw(localSeat), + 0f), FaceUp = true, Rested = false, }); @@ -811,15 +990,15 @@ namespace Vanguard.Board3D if (player == null) { - deck.SetCount(0, null, "DECK", Color.white); - drop.SetCount(0, null, "DROP", Color.white); + deck.SetCount(0, null, _config.showZoneLabels ? "DECK" : string.Empty, Color.white); + drop.SetCount(0, null, _config.showZoneLabels ? "DROP" : string.Empty, Color.white); return; } deck.SetCount( player.DeckCount, null, - "DECK", + _config.showZoneLabels ? "DECK" : string.Empty, new Color(0.20f, 0.34f, 0.60f)); var topDrop = player.Drop != null && player.Drop.Count > 0 @@ -828,7 +1007,7 @@ namespace Vanguard.Board3D drop.SetCount( player.Drop?.Count ?? 0, topDrop != null ? ResolveArt(topDrop.PrintingId).Front : null, - "DROP", + _config.showZoneLabels ? "DROP" : string.Empty, new Color(0.30f, 0.30f, 0.40f)); } @@ -863,6 +1042,14 @@ namespace Vanguard.Board3D private void Update() { + if (_boardLockReleaseAt > 0f + && (!AnyCardAnimating() + || Time.unscaledTime >= _boardLockReleaseAt)) + { + _boardLockReleaseAt = -1f; + _inputLock?.Release("board"); + } + UpdateHandView(); var delta = Time.unscaledDeltaTime; @@ -872,6 +1059,16 @@ namespace Vanguard.Board3D card.Tick(delta); } + _attackArrow?.Tick(delta); + UpdateAttackArrowPoints(); + + if (_inputLock != null && _inputLock.IsLocked) + { + if (_mode != PointerMode.None || _dragging) + CancelPointer(); + return; + } + HandleRightClick(); HandlePointer(); } @@ -1185,13 +1382,13 @@ namespace Vanguard.Board3D if (pooled == null) continue; pooled.gameObject.SetActive(true); - return pooled; + return ConfigureCardSpeeds(pooled); } if (_config.cardPrefab != null) { var instance = Instantiate(_config.cardPrefab, _cardsRoot); - return instance; + return ConfigureCardSpeeds(instance); } if (_cardsRoot == null) @@ -1200,7 +1397,19 @@ namespace Vanguard.Board3D _cardsRoot.SetParent(transform, false); } - return Card3D.Create(_cardsRoot, _config); + return ConfigureCardSpeeds(Card3D.Create(_cardsRoot, _config)); + } + + private Card3D ConfigureCardSpeeds(Card3D card) + { + if (card != null && _animationConfig != null) + { + card.SetAnimationSpeeds( + _animationConfig.cardMoveSpeed, + _animationConfig.cardRotateSpeed); + } + + return card; } private void ReleaseCard(Card3D card) @@ -1289,6 +1498,29 @@ namespace Vanguard.Board3D { if (_instance == this) _instance = null; + + _attackArrow?.Dispose(); + ReleaseBoardLock(); + } + + private void ReleaseBoardLock() + { + if (_boardLockReleaseAt <= 0f) + return; + + _boardLockReleaseAt = -1f; + _inputLock?.Release("board"); + } + + private bool AnyCardAnimating() + { + foreach (var card in _cards.Values) + { + if (card != null && card.IsAnimating) + return true; + } + + return false; } private sealed class HandCardBinding diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index a20b46d..aa06809 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -29,6 +29,9 @@ namespace Vanguard.UI private readonly MetaService _meta; private readonly Action _onExit; + private readonly AnimationConfig _animationConfig; + private readonly InputLockService _inputLock; + private bool _feedbackLockHeld; private readonly Dictionary _catalog = new Dictionary(StringComparer.Ordinal); private bool _catalogLoading; @@ -79,7 +82,10 @@ namespace Vanguard.UI private OverlayNavigationService _overlays; private ZoneBlockStripView _selfZones; private ZoneBlockStripView _opponentZones; + private CardActionMenuView _cardMenu; private RectTransform _phasePanel; + private RectTransform _combatStats; + private Text _combatStatsText; private bool _autoFinishMulligan; private float _autoFinishMulliganAt; private bool _autoEndBattleArmed; @@ -89,6 +95,9 @@ namespace Vanguard.UI { _meta = meta; _onExit = onExit; + _animationConfig = AnimationConfig.LoadOrDefault(); + _inputLock = new InputLockService(); + _inputLock.Changed += OnInputLockChanged; _root = UiKit.CreatePanel(parent, "BattleScreen", Background); var rootImage = _root.GetComponent(); @@ -187,8 +196,12 @@ namespace Vanguard.UI BuildOverlaysAndZones(); BuildPhasePanel(); + BuildCombatStats(); + _cardMenu = new CardActionMenuView(); + _cardMenu.Build(_root); _board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default); + _board3D.ConfigureRuntime(_animationConfig, _inputLock); _board3D.ArtProvider = ResolveBoardArt; _board3D.CardClicked += OnBoard3DCardClicked; _board3D.CardRightClicked += OnBoard3DCardRightClicked; @@ -223,6 +236,8 @@ namespace Vanguard.UI _autoFinishMulliganAt = 0f; _autoEndBattleArmed = false; _autoEndBattleAt = 0f; + _inputLock.ReleaseAll(); + _feedbackLockHeld = false; _logLines.Clear(); RebuildLog(); _feedbackQueue.Clear(); @@ -231,6 +246,7 @@ namespace Vanguard.UI _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); + _combatStats?.gameObject.SetActive(false); _board3D?.Clear(); _board3D?.SetVisible(true); _overlays?.CloseAll(); @@ -256,14 +272,18 @@ namespace Vanguard.UI _ = connection.DisposeAsync().AsTask(); } + _view = null; _root.gameObject.SetActive(false); _result.gameObject.SetActive(false); _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); _overlays?.CloseAll(); + _cardMenu?.Hide(); + _combatStats?.gameObject.SetActive(false); _board3D?.SetVisible(false); - _view = null; + ReleaseFeedbackLock(); + _inputLock.ReleaseAll(); } public void Tick() @@ -281,7 +301,7 @@ namespace Vanguard.UI private void UpdateAutoPhases() { - if (_view == null || _sending) + if (_view == null || _sending || _inputLock.IsLocked) return; var legal = _view.LegalIntents ?? new List(); @@ -356,9 +376,16 @@ namespace Vanguard.UI { _feedbackPlaying = false; _skipFeedback.gameObject.SetActive(false); + ReleaseFeedbackLock(); return; } + if (!_feedbackLockHeld) + { + _feedbackLockHeld = true; + _inputLock.Acquire("feedback"); + } + var step = _feedbackQueue.Dequeue(); _feedbackPlaying = true; _feedback.text = step.Text; @@ -376,6 +403,32 @@ namespace Vanguard.UI } } + private void ReleaseFeedbackLock() + { + if (!_feedbackLockHeld) + return; + + _feedbackLockHeld = false; + _inputLock.Release("feedback"); + } + + private void OnInputLockChanged(bool locked) + { + if (_view == null) + return; + + RebuildIntents(_view); + RebuildPhaseControls(_view); + } + + private void OnOverlayOpenChanged(bool open) + { + if (open) + _inputLock.Acquire("overlay"); + else + _inputLock.Release("overlay"); + } + private void UpdateFeedback() { if (!_feedbackPlaying) @@ -407,6 +460,7 @@ namespace Vanguard.UI _feedback.gameObject.SetActive(false); _flash.gameObject.SetActive(false); _skipFeedback.gameObject.SetActive(false); + ReleaseFeedbackLock(); } private readonly struct FeedbackStep @@ -598,46 +652,46 @@ namespace Vanguard.UI case AttackDeclaredView attack: ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot) + " → " + SlotName(attack.TargetSlot), - new Color(1f, 0.82f, 0.35f), 1.2f); + new Color(1f, 0.82f, 0.35f), _animationConfig.attackDeclare); break; case GuardAddedView guard: ShowFeedback("GUARD " + CardName(guard.PrintingId) + " +" + guard.Shield, - new Color(0.45f, 0.82f, 1f), 1.9f); + new Color(0.45f, 0.82f, 1f), _animationConfig.guard); break; case DriveCheckedView drive: ShowFeedback("DRIVE " + CardName(drive.PrintingId) + " · " + TriggerShort(drive.Trigger.ToString()), - new Color(0.72f, 0.88f, 1f), 1.5f); + new Color(0.72f, 0.88f, 1f), _animationConfig.driveCheck); break; case TriggerResolvedView trigger: ShowFeedback("TRIGGER " + CardName(trigger.PrintingId), - new Color(0.72f, 0.88f, 1f), 1.4f); + new Color(0.72f, 0.88f, 1f), _animationConfig.trigger); break; case BattleResolvedView battle: ShowFeedback(battle.Hit ? "HIT" : "BLOCK", battle.Hit ? new Color(1f, 0.45f, 0.35f) : new Color(0.45f, 0.82f, 1f), - 1.3f); + _animationConfig.battleResolved); break; case DamageTakenView damage: ShowFeedback("+" + damage.Amount + " DAMAGE", - new Color(1f, 0.30f, 0.26f), 1.6f, flash: true); + new Color(1f, 0.30f, 0.26f), _animationConfig.damage, flash: true); break; case HealedView: ShowFeedback("HEAL", - new Color(0.45f, 0.95f, 0.62f), 1.4f, flash: true); + new Color(0.45f, 0.95f, 0.62f), _animationConfig.heal, flash: true); break; case UnitRetiredView: ShowFeedback("RETIRE", - new Color(1f, 0.55f, 0.30f), 1.2f); + new Color(1f, 0.55f, 0.30f), _animationConfig.retireDuration); break; case InterceptedView intercept: ShowFeedback("INTERCEPT " + CardName(intercept.PrintingId), - new Color(0.55f, 0.82f, 1f), 1.9f); + new Color(0.55f, 0.82f, 1f), _animationConfig.intercept); break; case PerfectGuardView perfect: ShowFeedback("PERFECT GUARD " + CardName(perfect.SentinelPrintingId), - new Color(0.60f, 0.82f, 1f), 2.0f); + new Color(0.60f, 0.82f, 1f), _animationConfig.perfectGuard); break; } } @@ -734,6 +788,9 @@ namespace Vanguard.UI if (_sending || _connection == null || _cts == null || _view == null) return; + if (_inputLock.IsLocked && intent is not ConcedeIntent) + return; + if (_abilityDamageIndex >= 0) { if (intent is ActivateAbilityIntent activate) @@ -850,8 +907,10 @@ namespace Vanguard.UI if (_view == null) return; + _cardMenu?.Hide(); _board3D?.SetSelfPlayer(_view.You); _board3D?.Render(_view, BuildBoardRenderState()); + UpdateCombatStats(); UpdateZoneStrips(); RebuildIntents(_view); @@ -965,9 +1024,18 @@ namespace Vanguard.UI private void OnBoard3DCardClicked(BoardCardRef card) { - if (_view == null || _sending) + if (_view == null || _sending || _inputLock.IsLocked) return; + var targetSelection = _pendingIntents.Count > 0 + || IsMulliganSelecting() + || IsDamageChoice() + || IsAbilityDamageTargeting() + || _view.Trigger != null + || _view.Choice != null + || _view.Ability != null + || _view.Order != null; + switch (card.Zone) { case BoardZone.Hand: @@ -982,13 +1050,24 @@ namespace Vanguard.UI break; case BoardZone.Vanguard: - OnBoardTargetClicked(new BoardTarget( - card.Player, -1, card.PrintingId, card.InstanceId, false)); - break; - case BoardZone.RearGuard: - OnBoardTargetClicked(new BoardTarget( - card.Player, card.Slot, card.PrintingId, card.InstanceId, false)); + if (targetSelection) + { + OnBoardTargetClicked(new BoardTarget( + card.Player, + card.Zone == BoardZone.Vanguard ? -1 : card.Slot, + card.PrintingId, + card.InstanceId, + false)); + } + else if (card.Player == _view.You) + { + OpenCardMenu(card); + } + else + { + OpenCardDetails(card); + } break; case BoardZone.Damage: @@ -1000,14 +1079,144 @@ namespace Vanguard.UI break; case BoardZone.Guardian: - SetStatus("Карта в круге защиты.", Muted); + case BoardZone.Trigger: + OpenCardDetails(card); break; } } + private void OpenCardMenu(BoardCardRef card) + { + if (_view == null || _board3D == null) + return; + + var target = new BoardTarget( + card.Player, + card.Zone == BoardZone.Vanguard ? -1 : card.Slot, + card.PrintingId, + card.InstanceId, + false); + + var legal = _view.LegalIntents ?? new List(); + var sources = legal + .Where(intent => TryGetSource(intent, out var source) && source.Matches(target)) + .ToList(); + + var actions = new List(); + var attacks = sources.OfType().ToList(); + foreach (var group in attacks.GroupBy(attack => attack.BoosterSlot)) + { + var pending = group.ToList(); + var booster = group.Key; + var label = booster < 0 + ? "Атака" + : "Атака с поддержкой: " + CardName( + CardAt(_view.You, booster)?.PrintingId ?? string.Empty); + actions.Add(new CardActionMenuItem(label, () => + { + BeginTargetSelection(pending, "Выберите цель атаки."); + })); + } + + var moves = sources.OfType().ToList(); + if (moves.Count > 0) + { + actions.Add(new CardActionMenuItem("Переместить", () => + { + BeginTargetSelection(moves, "Выберите круг для перемещения."); + })); + } + + foreach (var intent in sources) + { + if (intent is AttackIntent or MoveIntent) + continue; + + var captured = intent; + actions.Add(new CardActionMenuItem( + Label(captured), + () => OnIntentClicked(captured))); + } + + actions.Add(new CardActionMenuItem("Подробнее", () => OpenCardDetails(card))); + + if (!_board3D.TryGetCardScreenPosition(card, out var screen)) + return; + + _inputLock.Acquire("card-menu"); + _cardMenu.Show( + screen, + CardName(card.PrintingId), + actions, + () => _inputLock.Release("card-menu")); + } + + private void BeginTargetSelection( + IEnumerable intents, + string prompt) + { + var list = intents.ToList(); + if (list.Count == 0) + return; + + _pendingIntents.Clear(); + _pendingIntents.AddRange(list); + RebuildHighlights(); + RebuildIntents(_view); + SetStatus(prompt, new Color(0.98f, 0.82f, 0.42f)); + } + + private void OpenCardDetails(BoardCardRef card) + { + if (_view == null || _overlays == null) + return; + + var view = FindCardView(card); + var entry = view != null + ? new CardListEntry(view) + : new CardListEntry(null, true, "Закрытая карта"); + _overlays.OpenCardDetail(entry); + } + + private CardView FindCardView(BoardCardRef card) + { + if (_view == null) + return null; + + var player = _view.Players.FirstOrDefault(item => item.Index == card.Player); + if (player == null) + return null; + + switch (card.Zone) + { + case BoardZone.Vanguard: + return player.Vanguard; + case BoardZone.RearGuard: + return player.RearGuards != null + && card.Slot >= 0 + && card.Slot < player.RearGuards.Length + ? player.RearGuards[card.Slot] + : null; + case BoardZone.Hand: + return player.Hand?.FirstOrDefault(item => item.InstanceId == card.InstanceId); + case BoardZone.Guardian: + return player.Guardian?.FirstOrDefault(item => item.InstanceId == card.InstanceId); + case BoardZone.Trigger: + return player.Trigger?.FirstOrDefault(item => item.InstanceId == card.InstanceId); + case BoardZone.Damage: + return player.FaceUpDamage != null + && card.Index >= 0 + && card.Index < player.FaceUpDamage.Count + ? player.FaceUpDamage[card.Index] + : null; + default: + return null; + } + } + private void OnBoard3DSlotClicked(BoardCardRef slot) { - if (_view == null || _sending) + if (_view == null || _sending || _inputLock.IsLocked) return; if (slot.Zone == BoardZone.Guardian) @@ -1018,7 +1227,7 @@ namespace Vanguard.UI private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target) { - if (_view == null || _sending) + if (_view == null || _sending || _inputLock.IsLocked) return; var legal = _view.LegalIntents ?? new List(); @@ -1079,7 +1288,7 @@ namespace Vanguard.UI private void OnBoard3DPileRightClicked(BoardCardRef pile) { - if (_view == null) + if (_view == null || _inputLock.IsLocked) return; if (pile.Zone == BoardZone.Soul) @@ -1088,7 +1297,7 @@ namespace Vanguard.UI private void OnBoard3DCardRightClicked(BoardCardRef card) { - if (_view == null) + if (_view == null || _inputLock.IsLocked) return; if (card.Zone == BoardZone.Vanguard) @@ -1097,7 +1306,7 @@ namespace Vanguard.UI private void OnBoard3DPileClicked(BoardCardRef pile) { - if (_view == null) + if (_view == null || _inputLock.IsLocked) return; if (pile.Zone == BoardZone.Soul) @@ -1171,6 +1380,7 @@ namespace Vanguard.UI PhaseLabel(captured), () => OnIntentClicked(captured), 14); + button.interactable = !_inputLock.IsLocked; UiKit.SetPreferredHeight(button, 40f); } } @@ -1191,6 +1401,7 @@ namespace Vanguard.UI private void BuildOverlaysAndZones() { _overlays = new OverlayNavigationService(_root, ResolveCatalogItem); + _overlays.OpenChanged += OnOverlayOpenChanged; _selfZones = new ZoneBlockStripView(); _selfZones.Build( @@ -1214,6 +1425,88 @@ namespace Vanguard.UI OpenZone(zone, _view != null ? 1 - _view.You : 1); } + private void BuildCombatStats() + { + _combatStats = UiKit.CreatePanel( + _root, + "CombatStats", + new Color(0.03f, 0.045f, 0.07f, 0.9f)); + _combatStats.anchorMin = new Vector2(0.5f, 1f); + _combatStats.anchorMax = new Vector2(0.5f, 1f); + _combatStats.pivot = new Vector2(0.5f, 1f); + _combatStats.anchoredPosition = new Vector2(0f, -18f); + _combatStats.sizeDelta = new Vector2(620f, 48f); + var statsImage = _combatStats.GetComponent(); + if (statsImage != null) + statsImage.raycastTarget = false; + + _combatStatsText = UiKit.CreateText( + _combatStats, + "Text", + string.Empty, + 20, + new Color(0.9f, 0.94f, 1f), + TextAnchor.MiddleCenter); + UiKit.Stretch(_combatStatsText.rectTransform); + _combatStatsText.fontStyle = FontStyle.Bold; + UiKit.MakeLiteral(_combatStatsText); + _combatStats.gameObject.SetActive(false); + } + + private void UpdateCombatStats() + { + if (_combatStats == null || _combatStatsText == null) + return; + + var battle = _view?.Battle; + if (battle == null) + { + _combatStats.gameObject.SetActive(false); + return; + } + + var defenderIndex = 1 - battle.AttackerPlayer; + var attacker = BattleCard(battle.AttackerPlayer, battle.AttackerSlot); + var defender = battle.InterceptorSlot >= 0 + ? BattleCard(defenderIndex, battle.InterceptorSlot) + : BattleCard(defenderIndex, battle.TargetSlot); + defender ??= BattleCard(defenderIndex, battle.TargetSlot); + + if (attacker == null || defender == null) + { + _combatStats.gameObject.SetActive(false); + return; + } + + var attackPower = attacker.Power; + var shield = defender.Power + battle.GuardShield; + var text = battle.Nullified + ? "POWER " + attackPower + " · NULLIFIED" + : "POWER " + attackPower + " vs SHIELD " + shield; + _combatStatsText.text = text; + _combatStatsText.color = battle.Nullified + ? new Color(0.7f, 0.76f, 0.9f) + : attackPower >= shield + ? new Color(1f, 0.72f, 0.32f) + : new Color(0.52f, 0.82f, 1f); + _combatStats.gameObject.SetActive(true); + } + + private CardView BattleCard(int playerIndex, int slot) + { + var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex); + if (player == null) + return null; + + if (slot < 0) + return player.Vanguard; + + return player.RearGuards != null + && slot < player.RearGuards.Length + ? player.RearGuards[slot] + : null; + } + private void UpdateZoneStrips() { if (_view == null) @@ -1241,6 +1534,9 @@ namespace Vanguard.UI _catalog.TryGetValue(printingId, out var item) ? item : null; private void OnDamageClicked(int displayIndex) { + if (_inputLock.IsLocked) + return; + if (IsDamageChoice()) { if (_view?.Choice?.Options == null) @@ -1317,7 +1613,8 @@ namespace Vanguard.UI private void SendMulligan() { - if (!IsMulliganSelecting() || _mulliganSelected.Count == 0) + if (!IsMulliganSelecting() || _mulliganSelected.Count == 0 + || _inputLock.IsLocked) return; var indexes = _mulliganSelected.OrderBy(index => index).ToList(); @@ -1330,7 +1627,7 @@ namespace Vanguard.UI private void OnBoardTargetClicked(BoardTarget target) { - if (_view == null || _sending) + if (_view == null || _sending || _inputLock.IsLocked) return; if (IsMulliganSelecting() && target.IsHand && target.Player == _view.You) @@ -1787,6 +2084,7 @@ namespace Vanguard.UI Label(captured), () => OnIntentClicked(captured), 16); + button.interactable = !_inputLock.IsLocked; UiKit.SetPreferredHeight(button, 46f); } diff --git a/client/Assets/Vanguard/Runtime/UI/Localization.cs b/client/Assets/Vanguard/Runtime/UI/Localization.cs index 45dc4b5..b713a98 100644 --- a/client/Assets/Vanguard/Runtime/UI/Localization.cs +++ b/client/Assets/Vanguard/Runtime/UI/Localization.cs @@ -61,6 +61,13 @@ namespace Vanguard.UI [" карт."] = " cards.", [" пыли)"] = " dust)", ["Атака "] = "Attack ", + ["Атака"] = "Attack", + ["Атака с поддержкой: "] = "Boosted attack: ", + ["Переместить"] = "Move", + ["Подробнее"] = "Details", + ["Нет доступных действий"] = "No available actions", + ["Выберите цель атаки."] = "Choose an attack target.", + ["Выберите круг для перемещения."] = "Choose a circle to move to.", ["В меню"] = "To menu", ["Готово"] = "Done", ["Журнал"] = "Log", diff --git a/client/Assets/Vanguard/Runtime/UI/Overlays/CardActionMenuView.cs b/client/Assets/Vanguard/Runtime/UI/Overlays/CardActionMenuView.cs new file mode 100644 index 0000000..1a29173 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/UI/Overlays/CardActionMenuView.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +namespace Vanguard.UI.Overlays +{ + public sealed class CardActionMenuItem + { + public CardActionMenuItem(string label, Action action, bool enabled = true) + { + Label = label; + Action = action; + Enabled = enabled; + } + + public string Label { get; } + public Action Action { get; } + public bool Enabled { get; } + } + + /// Экранное контекстное меню над картой. + public sealed class CardActionMenuView + { + private RectTransform _root; + private RectTransform _popup; + private Action _onClose; + + public bool IsVisible => _root != null && _root.gameObject.activeSelf; + + public void Build(Transform parent) + { + _root = UiKit.CreatePanel(parent, "CardActionMenu", new Color(0f, 0f, 0f, 0.01f)); + var image = _root.GetComponent(); + image.raycastTarget = true; + + var backdrop = _root.gameObject.AddComponent