diff --git a/client/Assets/Plugins/Vanguard.Shared/Vanguard.Shared.dll b/client/Assets/Plugins/Vanguard.Shared/Vanguard.Shared.dll index 1599495..4efa9d9 100644 Binary files a/client/Assets/Plugins/Vanguard.Shared/Vanguard.Shared.dll and b/client/Assets/Plugins/Vanguard.Shared/Vanguard.Shared.dll differ diff --git a/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs index 1112fb2..981d25f 100644 --- a/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs +++ b/client/Assets/Vanguard/Runtime/Battle/AnimationConfig.cs @@ -17,6 +17,25 @@ namespace Vanguard.Battle public float callMoveDuration = 0.55f; public float retireDuration = 0.8f; + [Header("3D UX: движение")] + public float moveDuration = 0.5f; + public float importantMoveDuration = 0.9f; + public float moveDelayBetweenCards = 0.18f; + public float arcHeight = 0.45f; + + [Header("3D UX: камера")] + public float cameraZoomDuration = 0.35f; + public float triggerRevealHold = 1.2f; + public float cameraReturnDuration = 0.4f; + public float cameraRezoomDuration = 0.3f; + + [Header("3D UX: стрелки")] + public float growDuration = 0.35f; + public float dashScrollSpeed = 1.5f; + public float boostGrowDuration = 0.3f; + public float boostThickness = 0.8f; + public float arrowFadeDuration = 0.2f; + [Header("Бой")] public float attackDeclare = 1.2f; public float guard = 1.9f; @@ -66,6 +85,19 @@ namespace Vanguard.Battle cardFlipDuration = Mathf.Max(0.05f, cardFlipDuration); callMoveDuration = Mathf.Max(0.05f, callMoveDuration); retireDuration = Mathf.Max(0.05f, retireDuration); + moveDuration = Mathf.Max(0.05f, moveDuration); + importantMoveDuration = Mathf.Max(0.05f, importantMoveDuration); + moveDelayBetweenCards = Mathf.Max(0f, moveDelayBetweenCards); + arcHeight = Mathf.Max(0f, arcHeight); + cameraZoomDuration = Mathf.Max(0.05f, cameraZoomDuration); + triggerRevealHold = Mathf.Max(0f, triggerRevealHold); + cameraReturnDuration = Mathf.Max(0.05f, cameraReturnDuration); + cameraRezoomDuration = Mathf.Max(0.05f, cameraRezoomDuration); + growDuration = Mathf.Max(0.05f, growDuration); + dashScrollSpeed = Mathf.Max(0f, dashScrollSpeed); + boostGrowDuration = Mathf.Max(0.05f, boostGrowDuration); + boostThickness = Mathf.Max(0.01f, boostThickness); + arrowFadeDuration = Mathf.Max(0.05f, arrowFadeDuration); attackDeclare = Mathf.Max(0.05f, attackDeclare); guard = Mathf.Max(0.05f, guard); driveCheck = Mathf.Max(0.05f, driveCheck); diff --git a/client/Assets/Vanguard/Runtime/Battle/ArrowConfig.cs b/client/Assets/Vanguard/Runtime/Battle/ArrowConfig.cs new file mode 100644 index 0000000..4830dce --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/ArrowConfig.cs @@ -0,0 +1,50 @@ +using UnityEngine; + +namespace Vanguard.Battle +{ + public enum ArrowKind + { + Attack, + Boost, + } + + [CreateAssetMenu(menuName = "Vanguard/Arrow Config", fileName = "ArrowConfig")] + public sealed class ArrowConfig : ScriptableObject + { + public Color attackColor = new Color(0.95f, 0.16f, 0.16f); + public Color boostColor = new Color(0.22f, 0.55f, 1f); + + [Min(0.01f)] public float growDuration = 0.35f; + [Min(0.01f)] public float fadeDuration = 0.2f; + [Min(0f)] public float dashScrollSpeed = 1.5f; + [Min(0.01f)] public float thickness = 0.075f; + [Min(0.01f)] public float boostThickness = 0.06f; + [Min(0.01f)] public float headLength = 0.32f; + [Min(0.01f)] public float headWidth = 0.16f; + [Min(0f)] public float yOffset = 0.22f; + [Min(0f)] public float arcHeight = 0.10f; + + private static ArrowConfig _default; + + public static ArrowConfig Default + { + get + { + if (_default == null) + { + _default = CreateInstance(); + _default.name = "ArrowConfig (runtime)"; + _default.hideFlags = HideFlags.DontSave; + } + + return _default; + } + } + + public static ArrowConfig LoadOrDefault() + { + var loaded = Resources.Load("ArrowConfig"); + return loaded != null ? loaded : Default; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Battle/ArrowConfig.cs.meta b/client/Assets/Vanguard/Runtime/Battle/ArrowConfig.cs.meta new file mode 100644 index 0000000..e535c93 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/ArrowConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6a3701087bff10942a9c17bee301638b \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs b/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs new file mode 100644 index 0000000..6038c22 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs @@ -0,0 +1,143 @@ +using UnityEngine; + +namespace Vanguard.Battle +{ + /// + /// Единственный владелец кинематографичного движения камеры. + /// Снимок исходного pose берётся один раз на цепочку и + /// восстанавливается идемпотентно. + /// + public sealed class CameraDirector + { + private struct Pose + { + public Vector3 Position; + public Quaternion Rotation; + public float FieldOfView; + } + + private readonly Camera _camera; + private Pose _origin; + private Pose _from; + private Pose _to; + private float _duration; + private float _elapsed; + private bool _moving; + private bool _hasOrigin; + + public CameraDirector(Camera camera) + { + _camera = camera; + } + + public bool IsActive => _hasOrigin && _camera != null; + public bool IsMoving => _moving; + + public void BeginChain() + { + if (_camera == null || _hasOrigin) + return; + + _origin = Capture(); + _hasOrigin = true; + } + + public void ZoomTo( + Vector3 target, + float duration, + float fieldOfView = 34f) + { + if (_camera == null) + return; + + BeginChain(); + var current = Capture(); + var targetPosition = target + + (Vector3.back * 3.4f) + + (Vector3.up * 2.6f); + var targetRotation = Quaternion.LookRotation( + target - targetPosition, + Vector3.up); + AnimateTo( + new Pose + { + Position = targetPosition, + Rotation = targetRotation, + FieldOfView = fieldOfView, + }, + duration); + } + + public void ReturnToOrigin(float duration) + { + if (!_hasOrigin) + return; + + AnimateTo(_origin, duration); + } + + public void Restore() + { + if (_camera == null || !_hasOrigin) + return; + + _moving = false; + Apply(_origin); + _hasOrigin = false; + } + + public void Tick(float deltaTime) + { + if (!_moving || _camera == null) + return; + + _elapsed += deltaTime; + var t = Mathf.Clamp01(_elapsed / Mathf.Max(0.01f, _duration)); + var eased = t * t * (3f - (2f * t)); + Apply(new Pose + { + Position = Vector3.Lerp(_from.Position, _to.Position, eased), + Rotation = Quaternion.Slerp( + _from.Rotation, + _to.Rotation, + eased), + FieldOfView = Mathf.Lerp( + _from.FieldOfView, + _to.FieldOfView, + eased), + }); + + if (t >= 1f) + _moving = false; + } + + private void AnimateTo(Pose target, float duration) + { + if (_camera == null) + return; + + _from = Capture(); + _to = target; + _duration = Mathf.Max(0.01f, duration); + _elapsed = 0f; + _moving = true; + } + + private Pose Capture() => new Pose + { + Position = _camera.transform.position, + Rotation = _camera.transform.rotation, + FieldOfView = _camera.fieldOfView, + }; + + private void Apply(Pose pose) + { + if (_camera == null) + return; + + _camera.transform.position = pose.Position; + _camera.transform.rotation = pose.Rotation; + _camera.fieldOfView = pose.FieldOfView; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs.meta b/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs.meta new file mode 100644 index 0000000..432929c --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/CameraDirector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 131f7624a241a2140b5ed6b9bc2ff0a5 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs b/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs new file mode 100644 index 0000000..5109bd9 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs @@ -0,0 +1,153 @@ +using UnityEngine; +using Vanguard.Battle; + +namespace Vanguard.Battle +{ + /// + /// Оркестратор кинематографичной проверки триггера: + /// zoom → reveal hold → return; повторный zoom в цепочке. + /// + public sealed class TriggerCheckSequence + { + private enum Stage + { + Idle, + Zooming, + Holding, + Returning, + } + + private readonly CameraDirector _camera; + private readonly InputLockService _inputLock; + + private Stage _stage; + private float _deadline; + private bool _chainActive; + private bool _restoreRequested; + private bool _lockHeld; + + public TriggerCheckSequence( + CameraDirector camera, + InputLockService inputLock) + { + _camera = camera; + _inputLock = inputLock; + } + + public bool IsBusy => _stage != Stage.Idle; + + public void ShowTrigger( + AnimationConfig config, + Vector3 worldPoint) + { + if (_camera == null) + return; + + if (!_chainActive) + { + _camera.BeginChain(); + _chainActive = true; + } + + Acquire(); + _restoreRequested = false; + var zoom = config != null + ? config.cameraZoomDuration + : 0.35f; + var hold = config != null + ? config.triggerRevealHold + : 1.2f; + + _camera.ZoomTo(worldPoint, zoom); + _stage = Stage.Zooming; + _deadline = Time.unscaledTime + zoom + hold; + } + + public void ReturnForChoice(AnimationConfig config) + { + if (!_chainActive) + return; + + var duration = config != null + ? config.cameraReturnDuration + : 0.4f; + _camera.ReturnToOrigin(duration); + _stage = Stage.Returning; + _deadline = Time.unscaledTime + duration; + } + + public void EndChain(AnimationConfig config) + { + if (!_chainActive) + { + Release(); + return; + } + + var duration = config != null + ? config.cameraReturnDuration + : 0.4f; + _restoreRequested = true; + _camera.ReturnToOrigin(duration); + _stage = Stage.Returning; + _deadline = Time.unscaledTime + duration; + } + + public void Tick(AnimationConfig config) + { + if (_stage == Stage.Idle) + return; + + if (_stage == Stage.Zooming + && Time.unscaledTime >= _deadline) + { + var duration = config != null + ? config.cameraReturnDuration + : 0.4f; + _camera.ReturnToOrigin(duration); + _stage = Stage.Returning; + _deadline = Time.unscaledTime + duration; + return; + } + + if (_stage == Stage.Returning + && Time.unscaledTime >= _deadline) + { + _stage = Stage.Idle; + Release(); + if (_restoreRequested) + { + _restoreRequested = false; + _chainActive = false; + _camera.Restore(); + } + } + } + + private void Acquire() + { + if (_lockHeld) + return; + + _inputLock?.Acquire("trigger-sequence"); + _lockHeld = true; + } + + private void Release() + { + if (!_lockHeld) + return; + + _inputLock?.Release("trigger-sequence"); + _lockHeld = false; + } + + public void Cancel() + { + _stage = Stage.Idle; + _chainActive = false; + Release(); + _camera?.Restore(); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs.meta b/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs.meta new file mode 100644 index 0000000..0df6c50 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Battle/TriggerCheckSequence.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c2a978244b3921c4898d2446f192e677 \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation.meta b/client/Assets/Vanguard/Runtime/Board3D/Animation.meta new file mode 100644 index 0000000..fac1b61 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Animation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8590de763c9f81d40b1acc931836a9ac +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation/ArrowDirector.cs b/client/Assets/Vanguard/Runtime/Board3D/Animation/ArrowDirector.cs new file mode 100644 index 0000000..30358f1 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Animation/ArrowDirector.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using UnityEngine; +using Vanguard.Battle; + +namespace Vanguard.Board3D +{ + /// + /// Единое управление красной attack-стрелкой и синими boost-стрелками. + /// Геометрия/анимация общая, различие — цвет и толщина. + /// + public sealed class ArrowDirector + { + private readonly Transform _parent; + private AnimationConfig _animation; + private ArrowConfig _config; + private AttackArrowView _attack; + private readonly List _boosts = + new List(); + + public ArrowDirector( + Transform parent, + AnimationConfig animation, + ArrowConfig config) + { + _parent = parent; + _animation = animation; + _config = config != null ? config : ArrowConfig.Default; + } + + public bool AttackVisible => + _attack != null && _attack.IsVisible; + + public void SetConfig(AnimationConfig animation, ArrowConfig config) + { + _animation = animation; + _config = config != null ? config : ArrowConfig.Default; + if (_attack != null) + { + _attack.SetConfig(_animation); + _attack.SetStyle( + _config.attackColor, + _config.thickness, + _animation != null ? _animation.growDuration : _config.growDuration, + _config.fadeDuration, + _config.arcHeight); + } + } + + public void SetAttack(Vector3 from, Vector3 to) + { + EnsureAttack(); + _attack.Show(from, to); + _attack.SetPoints(from, to); + } + + public void HideAttack() + { + _attack?.Hide(); + } + + public void SetBoosts(IReadOnlyList<(Vector3 From, Vector3 To)> boosts) + { + for (var i = 0; i < boosts.Count; i++) + { + var arrow = i < _boosts.Count + ? _boosts[i] + : null; + if (arrow == null) + { + arrow = new AttackArrowView(); + arrow.Build(_parent, _animation); + _boosts.Add(arrow); + } + + arrow.SetStyle( + _config.boostColor, + _config.boostThickness, + _animation != null + ? _animation.boostGrowDuration + : _config.growDuration, + _config.fadeDuration, + _config.arcHeight); + arrow.Show(boosts[i].From, boosts[i].To); + } + + for (var i = boosts.Count; i < _boosts.Count; i++) + _boosts[i].Hide(); + } + + public void HideAll() + { + _attack?.Hide(); + foreach (var arrow in _boosts) + arrow.Hide(); + } + + public void HideImmediate() + { + _attack?.HideImmediate(); + foreach (var arrow in _boosts) + arrow.HideImmediate(); + } + + public void Tick(float deltaTime) + { + _attack?.Tick(deltaTime); + foreach (var arrow in _boosts) + arrow?.Tick(deltaTime); + } + + public void Dispose() + { + _attack?.Dispose(); + _attack = null; + foreach (var arrow in _boosts) + arrow?.Dispose(); + _boosts.Clear(); + } + + private void EnsureAttack() + { + if (_attack != null) + return; + + _attack = new AttackArrowView(); + _attack.Build(_parent, _animation); + _attack.SetStyle( + _config.attackColor, + _config.thickness, + _animation != null ? _animation.growDuration : _config.growDuration, + _config.fadeDuration, + _config.arcHeight); + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation/ArrowDirector.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/Animation/ArrowDirector.cs.meta new file mode 100644 index 0000000..5d2664b --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Animation/ArrowDirector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 509a80c7422e7dc4cba2cdc07c47177a \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs new file mode 100644 index 0000000..1ea5082 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using UnityEngine; +using Vanguard.Battle; +using Vanguard.Board3D; + +namespace Vanguard.Board3D +{ + /// + /// Единый планировщик карточных перемещений. Карточки анимируются + /// Card3D, аниматор отвечает за stagger, очередь и InputLock. + /// + public sealed class CardMoveAnimator + { + private sealed class Request + { + public Card3D Card; + public Vector3 FromPosition; + public Quaternion FromRotation; + public Vector3 FromScale; + public float Duration; + public float ArcHeight; + } + + private readonly Queue _queue = new Queue(); + private readonly List _active = new List(); + private readonly InputLockService _inputLock; + private AnimationConfig _config; + private float _delay; + private bool _locked; + + public CardMoveAnimator( + InputLockService inputLock, + AnimationConfig config) + { + _inputLock = inputLock; + _config = config; + } + + public bool IsBusy => _queue.Count > 0 || _active.Count > 0; + + public void SetConfig(AnimationConfig config) + { + _config = config; + } + + public void Enqueue( + Card3D card, + Vector3 fromPosition, + Quaternion fromRotation, + Vector3 fromScale, + float duration, + float arcHeight) + { + if (card == null || card.IsDragging) + return; + + EnsureLock(); + _queue.Enqueue(new Request + { + Card = card, + FromPosition = fromPosition, + FromRotation = fromRotation, + FromScale = fromScale, + Duration = duration, + ArcHeight = arcHeight, + }); + } + + public void Tick(float deltaTime) + { + if (_active.Count > 0) + { + for (var i = _active.Count - 1; i >= 0; i--) + { + if (_active[i] == null || !_active[i].IsMoving) + _active.RemoveAt(i); + } + } + + if (_queue.Count > 0) + { + _delay -= Mathf.Max(0f, deltaTime); + if (_delay <= 0f) + StartNext(); + } + + if (!IsBusy) + ReleaseLock(); + } + + public void CancelAll(bool snapCards = false) + { + _queue.Clear(); + foreach (var card in _active) + { + if (card != null) + card.StopMove(snapCards); + } + + _active.Clear(); + _delay = 0f; + ReleaseLock(); + } + + private void StartNext() + { + if (_queue.Count == 0) + return; + + var request = _queue.Dequeue(); + var card = request.Card; + if (card == null || card.IsDragging) + { + _delay = 0f; + return; + } + + card.StartArcMove( + request.FromPosition, + request.FromRotation, + request.FromScale, + request.Duration, + request.ArcHeight); + + if (card.IsMoving) + _active.Add(card); + _delay = Mathf.Max(0.01f, _config != null + ? _config.moveDelayBetweenCards + : 0.18f); + } + + private void EnsureLock() + { + if (_locked) + return; + + _inputLock?.Acquire("card-move"); + _locked = true; + } + + private void ReleaseLock() + { + if (!_locked) + return; + + _inputLock?.Release("card-move"); + _locked = false; + } + } +} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs.meta b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs.meta new file mode 100644 index 0000000..cd88354 --- /dev/null +++ b/client/Assets/Vanguard/Runtime/Board3D/Animation/CardMoveAnimator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 783f38d9ba797aa4a93d7d48752d83bb \ No newline at end of file diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs index 1307519..21259f1 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs @@ -82,6 +82,8 @@ namespace Vanguard.Board3D public float damageX = 3.0f; [Tooltip("Расстояние от центра стола до колоды и дропа.")] public float pileX = 2.95f; + [Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")] + public bool dropZoneFaceDown = false; [Header("Отладка")] [Tooltip("Показывать подписи зон на 3D-столе.")] diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs index 3ae312e..9f7684a 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs @@ -232,6 +232,7 @@ namespace Vanguard.Board3D public bool AbilityDamageTargeting { get; set; } public int AbilityDamagePlayer { get; set; } = -1; public int AbilityDamageIndex { get; set; } = -1; + public HashSet AbilityDamageIndexes { get; } = new HashSet(); public bool MatchFinished { get; set; } } diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs b/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs index ede4cbd..244065e 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/AttackArrowView.cs @@ -17,12 +17,19 @@ namespace Vanguard.Board3D private LineRenderer _shaft; private LineRenderer _head; private Material _material; + private Texture2D _dashTexture; private AnimationConfig _config; private Vector3 _from; private Vector3 _to; private bool _visible; private float _alpha; private float _widthScale = 1f; + private float _grow = 1f; + private float _growDuration = 0.35f; + private float _fadeDuration = 0.2f; + private float _thickness = ShaftWidth; + private float _arcHeight = 0.1f; + private Color _color = new Color(1f, 0.16f, 0.16f); public bool IsVisible => _visible || _alpha > 0.001f; @@ -37,6 +44,13 @@ namespace Vanguard.Board3D shader = Shader.Find("Unlit/Color"); _material = shader != null ? new Material(shader) : null; + if (_material != null) + { + _dashTexture = CreateDashTexture(); + _material.mainTexture = _dashTexture; + _material.mainTextureScale = new Vector2(2.5f, 1f); + } + _shaft = CreateLine("Shaft", ShaftWidth); _head = CreateLine("Head", ShaftWidth * 0.7f); _head.positionCount = 3; @@ -48,10 +62,29 @@ namespace Vanguard.Board3D _config = config; } + public void SetStyle( + Color color, + float thickness, + float growDuration, + float fadeDuration, + float arcHeight) + { + _color = color; + _thickness = Mathf.Max(0.01f, thickness); + _growDuration = Mathf.Max(0.01f, growDuration); + _fadeDuration = Mathf.Max(0.01f, fadeDuration); + _arcHeight = Mathf.Max(0f, arcHeight); + ApplyColor(); + } + public void Show(Vector3 from, Vector3 to) { SetPoints(from, to); - _visible = true; + if (!_visible) + { + _grow = 0f; + _visible = true; + } if (_root != null) _root.SetActive(true); @@ -82,10 +115,16 @@ namespace Vanguard.Board3D if (_root == null) return; + if (_visible && _grow < 1f) + _grow = Mathf.MoveTowards( + _grow, + 1f, + deltaTime / _growDuration); + var target = _visible ? 1f : 0f; var duration = _visible ? (_config != null ? _config.arrowShowDuration : 0.25f) - : (_config != null ? _config.arrowHideDuration : 0.2f); + : _fadeDuration; _alpha = Mathf.MoveTowards( _alpha, target, @@ -99,6 +138,13 @@ namespace Vanguard.Board3D _root.SetActive(true); _widthScale = 0.55f + (_alpha * 0.45f); + if (_material != null && _config != null) + { + var offset = _material.mainTextureOffset; + offset.x += _config.dashScrollSpeed * deltaTime; + _material.mainTextureOffset = offset; + } + UpdateGeometry(); ApplyColor(); } @@ -114,6 +160,15 @@ namespace Vanguard.Board3D _root = null; } + if (_dashTexture != null) + { + if (Application.isPlaying) + Object.Destroy(_dashTexture); + else + Object.DestroyImmediate(_dashTexture); + _dashTexture = null; + } + if (_material != null) { if (Application.isPlaying) @@ -124,6 +179,31 @@ namespace Vanguard.Board3D } } + private static Texture2D CreateDashTexture() + { + const int width = 16; + const int height = 4; + var texture = new Texture2D(width, height, TextureFormat.RGBA32, false) + { + wrapMode = TextureWrapMode.Repeat, + filterMode = FilterMode.Bilinear, + name = "ArrowDash", + }; + + for (var x = 0; x < width; x++) + { + var on = x < 9; + var color = on + ? new Color(1f, 1f, 1f, 1f) + : new Color(1f, 1f, 1f, 0.18f); + for (var y = 0; y < height; y++) + texture.SetPixel(x, y, color); + } + + texture.Apply(false, true); + return texture; + } + private LineRenderer CreateLine(string name, float width) { var go = new GameObject(name); @@ -135,11 +215,12 @@ namespace Vanguard.Board3D line.numCapVertices = 4; line.numCornerVertices = 4; line.alignment = LineAlignment.View; - line.textureMode = LineTextureMode.Stretch; + line.textureMode = LineTextureMode.Tile; line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; line.receiveShadows = false; if (_material != null) - line.material = _material; + line.sharedMaterial = _material; + return line; } @@ -148,30 +229,41 @@ namespace Vanguard.Board3D if (_shaft == null || _head == null) return; - var direction = _to - _from; + var end = Vector3.Lerp(_from, _to, _grow); + var direction = end - _from; + if (direction.sqrMagnitude < 0.0001f) + direction = _to - _from; if (direction.sqrMagnitude < 0.0001f) direction = Vector3.forward; direction.Normalize(); + var mid = Vector3.Lerp(_from, end, 0.5f) + + (Vector3.up * _arcHeight * Mathf.Sin(_grow * Mathf.PI)); + _shaft.positionCount = 3; _shaft.SetPosition(0, _from); - _shaft.SetPosition(1, _to); - _shaft.widthMultiplier = ShaftWidth * _widthScale; + _shaft.SetPosition(1, mid); + _shaft.SetPosition(2, end); + _shaft.widthMultiplier = _thickness * _widthScale; - var tip = _to; - var back = _to - (direction * HeadLength); + var tip = end; + var back = tip - (direction * 0.32f); var side = Vector3.Cross(Vector3.up, direction).normalized; if (side.sqrMagnitude < 0.0001f) side = Vector3.right; - var half = side * (HeadWidth * _widthScale); + var half = side * (0.16f * _widthScale); _head.SetPosition(0, back + half); _head.SetPosition(1, tip); _head.SetPosition(2, back - half); - _head.widthMultiplier = ShaftWidth * 0.7f * _widthScale; + _head.widthMultiplier = _thickness * 0.7f * _widthScale; } private void ApplyColor() { - var color = new Color(1f, 0.72f, 0.26f, _alpha * 0.92f); + var color = new Color( + _color.r, + _color.g, + _color.b, + _alpha * 0.92f); foreach (var line in new[] { _shaft, _head }) { if (line == null) diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs index daf3ed1..11fa2b8 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs @@ -27,6 +27,14 @@ namespace Vanguard.Board3D private Vector3 _baseScale = Vector3.one; private bool _immediate; + private bool _arcMoving; + private Vector3 _arcFromPosition; + private Quaternion _arcFromRotation; + private Vector3 _arcFromScale; + private float _arcDuration; + private float _arcElapsed; + private float _arcHeight; + private bool _dragging; private Vector3 _dragOriginPosition; private Quaternion _dragOriginRotation; @@ -42,9 +50,11 @@ namespace Vanguard.Board3D public Quaternion TargetRotation => _targetRotation; public bool IsAnimating => !_dragging - && (Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f + && (_arcMoving + || Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f || Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f || Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f); + public bool IsMoving => _arcMoving; public string StatsText => _stats != null && _stats.gameObject.activeSelf ? _stats.text : null; @@ -223,6 +233,64 @@ namespace Vanguard.Board3D Snap(); } + public void PlaceImmediate( + Vector3 position, + Quaternion rotation, + Vector3 scale) + { + _arcMoving = false; + _immediate = false; + transform.position = position; + transform.rotation = rotation; + transform.localScale = scale; + } + + public void StartArcMove( + Vector3 fromPosition, + Quaternion fromRotation, + Vector3 fromScale, + float duration, + float arcHeight) + { + if (_dragging) + return; + + _arcMoving = false; + _immediate = false; + + var distance = Vector3.Distance(fromPosition, _targetPosition); + var angle = Quaternion.Angle(fromRotation, _targetRotation); + var scaleDistance = Vector3.Distance(fromScale, _targetScale); + if (duration <= 0.001f + || (distance < 0.002f && angle < 0.2f && scaleDistance < 0.002f)) + { + Snap(); + return; + } + + _arcFromPosition = fromPosition; + _arcFromRotation = fromRotation; + _arcFromScale = fromScale; + _arcDuration = Mathf.Max(0.01f, duration); + _arcElapsed = 0f; + _arcHeight = Mathf.Max(0f, arcHeight); + _arcMoving = true; + + transform.position = fromPosition; + transform.rotation = fromRotation; + transform.localScale = fromScale; + } + + public void StopMove(bool snap) + { + if (!_arcMoving) + return; + + _arcMoving = false; + if (snap) + Snap(); + } + public void SetVisible(bool visible) { gameObject.SetActive(visible); @@ -296,6 +364,7 @@ namespace Vanguard.Board3D public void Snap() { + _arcMoving = false; transform.position = _targetPosition; transform.rotation = _targetRotation; transform.localScale = _targetScale; @@ -307,6 +376,31 @@ namespace Vanguard.Board3D if (_dragging || deltaTime <= 0f) return; + if (_arcMoving) + { + _arcElapsed += deltaTime; + var t = Mathf.Clamp01(_arcElapsed / _arcDuration); + var eased = t * t * (3f - (2f * t)); + var position = Vector3.Lerp( + _arcFromPosition, + _targetPosition, + eased); + position.y += Mathf.Sin(t * Mathf.PI) * _arcHeight; + transform.position = position; + transform.rotation = Quaternion.Slerp( + _arcFromRotation, + _targetRotation, + eased); + transform.localScale = Vector3.Lerp( + _arcFromScale, + _targetScale, + eased); + + if (t >= 1f) + Snap(); + return; + } + if (_immediate) { Snap(); diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs index a4e19fd..e389193 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using UnityEngine; @@ -25,6 +25,8 @@ namespace Vanguard.Board3D private readonly Dictionary _cards = new Dictionary(StringComparer.Ordinal); private readonly Stack _pool = new Stack(); + private CardMoveAnimator _moveAnimator; + private CameraDirector _cameraDirector; private readonly List _piles = new List(); private readonly BoardZoneView[] _damageZones = new BoardZoneView[2]; private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2]; @@ -53,7 +55,7 @@ namespace Vanguard.Board3D private BoardZoneView _guardianZone; private BoardZoneView _dropIndicator; - private AttackArrowView _attackArrow; + private ArrowDirector _arrowDirector; private Pile3D _deckSelf; private Pile3D _deckOpponent; private Pile3D _dropSelf; @@ -80,9 +82,18 @@ namespace Vanguard.Board3D public PlaymatLayout Layout => _layout; public int SelfPlayer => _selfPlayer; public bool AttackArrowVisible => - _attackArrow != null && _attackArrow.IsVisible; + _arrowDirector != null && _arrowDirector.AttackVisible; + public CardMoveAnimator MoveAnimator => _moveAnimator; + public CameraDirector CameraDirector => _cameraDirector; public BoardArtProvider ArtProvider { get; set; } + public Vector3 TriggerCameraPoint(int player) + { + var localSeat = _layout.ToLocalSeat(player, _selfPlayer); + var position = _layout.TriggerPosition(localSeat, 0, 1); + return position + (Vector3.up * 0.5f); + } + public bool TryGetCardScreenPosition(BoardCardRef reference, out Vector2 screen) { screen = default; @@ -118,7 +129,13 @@ namespace Vanguard.Board3D { _animationConfig = animationConfig; _inputLock = inputLock; - _attackArrow?.SetConfig(_animationConfig); + _arrowDirector?.SetConfig( + _animationConfig, + ArrowConfig.LoadOrDefault()); + _moveAnimator ??= new CardMoveAnimator( + _inputLock, + _animationConfig); + _moveAnimator.SetConfig(_animationConfig); if (_animationConfig == null) return; @@ -181,6 +198,7 @@ namespace Vanguard.Board3D _cardsRoot.SetParent(transform, false); EnsureCamera(); + _cameraDirector = new CameraDirector(_camera); SetVisible(false); } @@ -207,8 +225,9 @@ namespace Vanguard.Board3D { CancelPointer(); _hasRendered = false; + _moveAnimator?.CancelAll(snapCards: false); _handBindings.Clear(); - _attackArrow?.HideImmediate(); + _arrowDirector?.HideImmediate(); ReleaseBoardLock(); } else @@ -241,21 +260,74 @@ namespace Vanguard.Board3D var specs = new List(40); BuildSpecs(view, state, specs); + _moveAnimator ??= new CardMoveAnimator( + _inputLock, + _animationConfig != null + ? _animationConfig + : AnimationConfig.Default); + + var previousByInstance = new Dictionary(); + var previousKeyByInstance = new Dictionary(); + foreach (var pair in _cards) + { + var previous = pair.Value; + if (previous == null || previous.Ref.InstanceId == 0) + continue; + + previousByInstance[previous.Ref.InstanceId] = previous; + previousKeyByInstance[previous.Ref.InstanceId] = pair.Key; + } + 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) + + var instanceId = spec.Ref.InstanceId; + var existingKey = _cards.TryGetValue(spec.Key, out var card) + && card != null; + var sameInstance = existingKey + && card.Ref.InstanceId == instanceId; + var transfer = false; + + if (!sameInstance + && instanceId != 0 + && previousByInstance.TryGetValue(instanceId, out var previous)) { + if (_cards.TryGetValue(spec.Key, out var displaced) + && displaced != null + && displaced != previous) + { + ReleaseCard(displaced); + } + + if (previousKeyByInstance.TryGetValue(instanceId, out var oldKey) + && oldKey != spec.Key) + { + _cards.Remove(oldKey); + } + + card = previous; + transfer = true; + } + else if (!existingKey || !sameInstance) + { + if (existingKey && card != null) + ReleaseCard(card); card = RentCard(); - _cards[spec.Key] = card; } - var changed = !card.gameObject.activeSelf - || card.Ref.InstanceId != spec.Ref.InstanceId - || card.Ref.Zone != spec.Ref.Zone; + _cards[spec.Key] = card; + + var wasActive = card.gameObject.activeSelf; + var fromPosition = card.transform.position; + var fromRotation = card.transform.rotation; + var fromScale = card.transform.localScale; + var fromInstance = card.Ref.InstanceId; + var fromZone = card.Ref.Zone; + card.Bind(spec.Ref); card.SetArt(spec.Art.Front, spec.Art.Accent); card.SetPose( @@ -263,9 +335,64 @@ namespace Vanguard.Board3D spec.BaseRotation, spec.FaceUp, spec.Rested, - immediateAll || changed, + immediateAll, spec.DamageLayout, spec.Scale * Mathf.Max(0.05f, _config.cardScale)); + + var moved = !immediateAll + && (!wasActive + || transfer + || !existingKey + || fromInstance != instanceId + || fromZone != spec.Ref.Zone); + + if (moved) + { + Vector3 sourcePosition; + Quaternion sourceRotation; + Vector3 sourceScale; + if (transfer + || (existingKey && fromInstance == instanceId)) + { + sourcePosition = fromPosition; + sourceRotation = fromRotation; + sourceScale = fromScale; + } + else + { + var source = SourcePose(spec); + sourcePosition = source.Position; + sourceRotation = source.Rotation; + sourceScale = Vector3.one * Mathf.Max( + 0.05f, + spec.Scale * _config.cardScale); + card.PlaceImmediate( + sourcePosition, + sourceRotation, + sourceScale); + } + + var important = spec.Ref.Zone is + BoardZone.Trigger or BoardZone.Damage or BoardZone.Drop; + var duration = important + ? _animationConfig != null + ? _animationConfig.importantMoveDuration + : 0.9f + : _animationConfig != null + ? _animationConfig.moveDuration + : 0.5f; + var arc = _animationConfig != null + ? _animationConfig.arcHeight + : 0.45f; + _moveAnimator.Enqueue( + card, + sourcePosition, + sourceRotation, + sourceScale, + duration, + arc); + } + card.SetHighlight(spec.Highlight); card.SetStats(spec.Stats, spec.StatsModified); @@ -309,36 +436,82 @@ namespace Vanguard.Board3D } public void Clear() { + _moveAnimator?.CancelAll(snapCards: false); ClearCards(); _handBindings.Clear(); _view = null; _hasRendered = false; - _attackArrow?.HideImmediate(); + _arrowDirector?.HideImmediate(); ReleaseBoardLock(); } private void UpdateAttackArrowState(MatchView view) { - if (_attackArrow == null) + if (_arrowDirector == null) return; if (view?.Battle == null || !TryResolveAttackPoints(view, out var from, out var to)) { - _attackArrow.Hide(); + _arrowDirector.HideAll(); return; } - _attackArrow.Show(from, to); + _arrowDirector.SetAttack(from, to); + UpdateBoostArrows(view); } private void UpdateAttackArrowPoints() { - if (_attackArrow == null || !_attackArrow.IsVisible || _view?.Battle == null) + if (_arrowDirector == null || _view?.Battle == null) return; if (TryResolveAttackPoints(_view, out var from, out var to)) - _attackArrow.SetPoints(from, to); + _arrowDirector.SetAttack(from, to); + + UpdateBoostArrows(_view); + } + + private void UpdateBoostArrows(MatchView view) + { + if (_arrowDirector == null || view?.Battle == null) + return; + + var battle = view.Battle; + var boosts = new List<(Vector3 From, Vector3 To)>(); + if (battle.BoosterSlot >= 0) + { + var attacker = BattleCard( + view, + battle.AttackerPlayer, + battle.AttackerSlot); + var booster = BattleCard( + view, + battle.AttackerPlayer, + battle.BoosterSlot); + if (attacker != null && booster != null) + { + var boosterCenter = ArrowAnchor( + BoardCardRef.Field( + battle.AttackerPlayer, + battle.BoosterSlot, + booster), + battle.AttackerPlayer, + battle.BoosterSlot); + var attackerCenter = ArrowAnchor( + BoardCardRef.Field( + battle.AttackerPlayer, + battle.AttackerSlot, + attacker), + battle.AttackerPlayer, + battle.AttackerSlot); + boosts.Add(( + CardEdge(boosterCenter, attackerCenter), + CardEdge(attackerCenter, boosterCenter))); + } + } + + _arrowDirector.SetBoosts(boosts); } private bool TryResolveAttackPoints( @@ -358,14 +531,16 @@ namespace Vanguard.Board3D if (attacker == null || defender == null) return false; - from = ArrowAnchor( + var attackerCenter = ArrowAnchor( BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker), battle.AttackerPlayer, battle.AttackerSlot); - to = ArrowAnchor( + var defenderCenter = ArrowAnchor( BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender), defenderIndex, battle.TargetSlot); + from = CardEdge(attackerCenter, defenderCenter); + to = CardEdge(defenderCenter, attackerCenter); return true; } @@ -379,6 +554,20 @@ namespace Vanguard.Board3D + (Vector3.up * 0.2f); } + private Vector3 CardEdge(Vector3 center, Vector3 toward) + { + var direction = toward - center; + direction.y = 0f; + if (direction.sqrMagnitude < 0.0001f) + return center; + + direction.Normalize(); + var radius = Mathf.Max( + _config.cardWidth, + _config.cardHeight) * 0.5f * _config.cardScale; + return center + (direction * radius); + } + private CardView BattleCard(MatchView view, int playerIndex, int slot) { var player = view?.Players.FirstOrDefault(item => item.Index == playerIndex); @@ -615,12 +804,17 @@ namespace Vanguard.Board3D private void BuildAttackArrow() { - _attackArrow = new AttackArrowView(); - _attackArrow.Build(transform, _animationConfig); + _arrowDirector = new ArrowDirector( + transform, + _animationConfig, + ArrowConfig.LoadOrDefault()); } private void EnsureCamera() { + if (_cameraDirector != null && _cameraDirector.IsActive) + return; + if (_camera == null) _camera = Camera.main; if (_camera == null) @@ -794,7 +988,8 @@ namespace Vanguard.Board3D Position = _layout.TriggerPosition(localSeat, i, count), BaseRotation = Quaternion.Euler( 0f, - _layout.BaseYaw(localSeat), + _layout.BaseYaw(localSeat) + + (localSeat == 0 ? 90f : -90f), 0f), FaceUp = true, Rested = false, @@ -814,8 +1009,12 @@ namespace Vanguard.Board3D if (!damageChoice && !abilityCost) return null; - if (abilityCost && state.AbilityDamageIndex == index) + if (abilityCost + && (state.AbilityDamageIndex == index + || state.AbilityDamageIndexes.Contains(index))) + { return _config.selectedColor; + } return _config.highlightColor; } @@ -1009,7 +1208,11 @@ namespace Vanguard.Board3D : null; drop.SetCount( player.Drop?.Count ?? 0, - topDrop != null ? ResolveArt(topDrop.PrintingId).Front : null, + topDrop != null + ? (_config.dropZoneFaceDown + ? BoardTextures.Back + : ResolveArt(topDrop.PrintingId).Front) + : null, _config.showZoneLabels ? "DROP" : string.Empty, new Color(0.30f, 0.30f, 0.40f)); } @@ -1064,13 +1267,15 @@ namespace Vanguard.Board3D UpdateHandView(); var delta = Time.unscaledDeltaTime; + _moveAnimator?.Tick(delta); + _cameraDirector?.Tick(delta); foreach (var card in _cards.Values) { if (card != null) card.Tick(delta); } - _attackArrow?.Tick(delta); + _arrowDirector?.Tick(delta); UpdateAttackArrowPoints(); if (_inputLock != null && _inputLock.IsLocked) @@ -1454,6 +1659,25 @@ namespace Vanguard.Board3D ApplySlotHighlights(); } + private (Vector3 Position, Quaternion Rotation, Vector3 Scale) SourcePose( + CardSpec spec) + { + var localSeat = _layout.ToLocalSeat( + spec.Ref.Player, + _selfPlayer); + var position = _layout.DeckPosition(localSeat); + var rotation = Quaternion.Euler( + 0f, + _layout.BaseYaw(localSeat), + 0f); + return ( + position, + rotation, + Vector3.one * Mathf.Max( + 0.05f, + spec.Scale * _config.cardScale)); + } + private BoardArt ResolveArt(string printingId) { if (ArtProvider != null) @@ -1510,7 +1734,9 @@ namespace Vanguard.Board3D if (_instance == this) _instance = null; - _attackArrow?.Dispose(); + _moveAnimator?.CancelAll(snapCards: false); + _arrowDirector?.Dispose(); + _cameraDirector?.Restore(); ReleaseBoardLock(); } @@ -1525,6 +1751,9 @@ namespace Vanguard.Board3D private bool AnyCardAnimating() { + if (_moveAnimator != null && _moveAnimator.IsBusy) + return true; + foreach (var card in _cards.Values) { if (card != null && card.IsAnimating) @@ -1567,4 +1796,4 @@ namespace Vanguard.Board3D public Color? Highlight; } } -} +} \ 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 a5bc668..136f0c5 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -32,6 +32,7 @@ namespace Vanguard.UI private readonly Action _onExit; private readonly AnimationConfig _animationConfig; private readonly InputLockService _inputLock; + private TriggerCheckSequence _triggerSequence; private bool _feedbackLockHeld; private string _lastInputLockSummary; private readonly Dictionary _catalog = @@ -106,6 +107,7 @@ namespace Vanguard.UI private readonly HashSet _mulliganSelected = new HashSet(); private bool _mulliganUsed; private int _abilityDamageIndex = -1; + private readonly HashSet _abilityDamageIndexes = new HashSet(); private readonly HashSet _highlightSlots = new HashSet(); private GameBoard3D _board3D; @@ -256,6 +258,9 @@ namespace Vanguard.UI _board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default); _board3D.ConfigureRuntime(_animationConfig, _inputLock); + _triggerSequence = new TriggerCheckSequence( + _board3D.CameraDirector, + _inputLock); _board3D.ArtProvider = ResolveBoardArt; _board3D.CardClicked += OnBoard3DCardClicked; _board3D.CardRightClicked += OnBoard3DCardRightClicked; @@ -274,6 +279,9 @@ namespace Vanguard.UI CancelReconnect(); _view = null; _finished = false; + _triggerWasPending = false; + _lastTriggerKey = null; + _triggerSequence?.Cancel(); _confirmConcede = false; _concedeLabel.text = "Сдаться"; _concede.interactable = true; @@ -290,6 +298,7 @@ namespace Vanguard.UI _mulliganSelected.Clear(); _mulliganUsed = false; _abilityDamageIndex = -1; + _abilityDamageIndexes.Clear(); _pendingBoosterSlot = -1; _pendingBoostAttackerSlot = -2; ClearGuardSelection(); @@ -350,6 +359,9 @@ namespace Vanguard.UI } _view = null; + _triggerWasPending = false; + _lastTriggerKey = null; + _triggerSequence?.Cancel(); HideActionPrompt(); _root.gameObject.SetActive(false); _result.gameObject.SetActive(false); @@ -376,6 +388,7 @@ namespace Vanguard.UI UpdateClock(); UpdateFeedback(); + _triggerSequence?.Tick(_animationConfig); RefreshActionPrompt(); SanitizeInputLocks(); SyncAbilityPrompt(); @@ -767,10 +780,45 @@ namespace Vanguard.UI } Render(); + SyncTriggerSequence(); SyncAbilityPrompt(); SyncCardChoicePrompt(); } + private bool _triggerWasPending; + private string _lastTriggerKey; + + private void SyncTriggerSequence() + { + if (_triggerSequence == null || _view == null) + return; + + var trigger = _view.Trigger; + if (trigger != null && _view.ActivePlayer == _view.You) + { + var key = trigger.Player + ":" + trigger.PrintingId + + ":" + _view.EventSeq; + if (!_triggerWasPending + || !string.Equals(_lastTriggerKey, key, StringComparison.Ordinal)) + { + _triggerWasPending = true; + _lastTriggerKey = key; + _triggerSequence.ShowTrigger( + _animationConfig, + _board3D.TriggerCameraPoint(trigger.Player)); + } + + return; + } + + if (_triggerWasPending) + { + _triggerWasPending = false; + _lastTriggerKey = null; + _triggerSequence.EndChain(_animationConfig); + } + } + private void OnEvents(ServerEvents events) { if (events?.Events != null) @@ -1118,6 +1166,8 @@ namespace Vanguard.UI } _abilityDamageIndex = -1; + + _abilityDamageIndexes.Clear(); _pendingIntents.Clear(); _highlightSlots.Clear(); _pendingBoosterSlot = -1; @@ -1691,6 +1741,12 @@ namespace Vanguard.UI MatchFinished = _view != null && _view.IsFinished, }; + if (IsAbilityDamageTargeting()) + { + foreach (var index in _abilityDamageIndexes) + state.AbilityDamageIndexes.Add(index); + } + foreach (var key in _highlightSlots) state.Highlights.Add(key); @@ -2220,6 +2276,56 @@ namespace Vanguard.UI || text.Contains("встречн"); } + private int CounterBlastAmount(ActivateAbilityIntent intent) + { + if (intent == null || _view == null) + return 1; + + var card = InstanceAt(_view.You, intent.InstanceId); + var meta = ResolveCatalogItem(card?.PrintingId ?? string.Empty); + if (meta == null) + return 1; + + var text = ((meta.TextEn ?? string.Empty) + " " + + (meta.TextRu ?? string.Empty)).ToLowerInvariant(); + var markers = new[] + { + "counter-blast", + "counter blast", + "контр-бласт", + }; + foreach (var marker in markers) + { + var start = text.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + continue; + + var index = start + marker.Length; + while (index < text.Length + && (text[index] == ' ' + || text[index] == ':' + || text[index] == '(' + || text[index] == '[')) + { + index++; + } + + var value = 0; + while (index < text.Length + && text[index] >= '0' + && text[index] <= '9') + { + value = (value * 10) + (text[index] - '0'); + index++; + } + + if (value > 0) + return value; + } + + return 1; + } + private void ActivateAbility(ActivateAbilityIntent intent) { if (intent == null || _view == null || _pendingAbilityCost != null) @@ -2235,6 +2341,7 @@ namespace Vanguard.UI _pendingAbilityCost = null; _abilityCostDiscardInstanceId = 0; _abilityDamageIndex = -1; + _abilityDamageIndexes.Clear(); StartDiscardSelection( "Выберите карту в руке для сброса.", variants, @@ -2257,6 +2364,7 @@ namespace Vanguard.UI _pendingAbilityCost = intent; _abilityCostDiscardInstanceId = 0; _abilityDamageIndex = -1; + _abilityDamageIndexes.Clear(); _pendingIntents.Clear(); _highlightSlots.Clear(); SetStatus( @@ -2271,10 +2379,13 @@ namespace Vanguard.UI return; if (RequiresCounterBlast(_pendingAbilityCost) - && _abilityDamageIndex < 0) + && _abilityDamageIndexes.Count + != CounterBlastAmount(_pendingAbilityCost)) { SetStatus( - "Выберите карту урона для Counter Blast.", + "Выберите карты урона для Counter Blast: " + + _abilityDamageIndexes.Count + " / " + + CounterBlastAmount(_pendingAbilityCost) + ".", new Color(1f, 0.70f, 0.35f)); return; } @@ -2284,6 +2395,9 @@ namespace Vanguard.UI InstanceId = _pendingAbilityCost.InstanceId, DiscardPrintingId = _pendingAbilityCost.DiscardPrintingId, DamageIndex = _abilityDamageIndex, + DamageIndexes = _abilityDamageIndexes + .OrderBy(index => index) + .ToArray(), }; ClearAbilityCostSelection(); OnIntentClicked(activate); @@ -2294,6 +2408,7 @@ namespace Vanguard.UI _pendingAbilityCost = null; _abilityCostDiscardInstanceId = 0; _abilityDamageIndex = -1; + _abilityDamageIndexes.Clear(); } private void PruneAbilityCostSelection() @@ -2319,8 +2434,11 @@ namespace Vanguard.UI if (_pendingAbilityCost == null) return false; - return !RequiresCounterBlast(_pendingAbilityCost) - || _abilityDamageIndex >= 0; + if (!RequiresCounterBlast(_pendingAbilityCost)) + return true; + + return _abilityDamageIndexes.Count + == CounterBlastAmount(_pendingAbilityCost); } private void ToggleGuardFromDrop(BoardCardRef source) @@ -2727,6 +2845,7 @@ namespace Vanguard.UI _pendingAbilityCost = ability; _abilityCostDiscardInstanceId = discardInstanceId; _abilityDamageIndex = -1; + _abilityDamageIndexes.Clear(); Render(); return; } @@ -3129,11 +3248,18 @@ namespace Vanguard.UI else if (_pendingAbilityCost != null) { title = "ОПЛАТА"; - hint = RequiresCounterBlast(_pendingAbilityCost) - ? _abilityDamageIndex < 0 - ? "Выберите карту урона для Counter Blast." - : "Оплата выбрана. Подтвердите в панели действий." - : "Оплата выбрана. Подтвердите в панели действий."; + if (RequiresCounterBlast(_pendingAbilityCost)) + { + var required = CounterBlastAmount(_pendingAbilityCost); + hint = "Выберите карты урона для Counter Blast: " + + _abilityDamageIndexes.Count + " / " + required + "."; + if (_abilityDamageIndexes.Count >= required) + hint = "Оплата выбрана. Подтвердите в панели действий."; + } + else + { + hint = "Оплата выбрана. Подтвердите в панели действий."; + } } else if (_pendingBoosterSlot >= 0 && _pendingIntents.Count > 0) { @@ -3525,9 +3651,13 @@ namespace Vanguard.UI if (!IsAbilityDamageTargeting()) return; - _abilityDamageIndex = displayIndex; + if (!_abilityDamageIndexes.Remove(displayIndex)) + _abilityDamageIndexes.Add(displayIndex); + _abilityDamageIndex = _abilityDamageIndexes.Count > 0 + ? _abilityDamageIndexes.Min() + : -1; SetStatus( - "Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".", + "Выбрано карт урона: " + _abilityDamageIndexes.Count + " / " + CounterBlastAmount(_pendingAbilityCost) + ".", new Color(0.45f, 0.95f, 0.62f)); Render(); } @@ -4283,4 +4413,4 @@ namespace Vanguard.UI } } } -} +} \ No newline at end of file diff --git a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs index 952d998..c9454a2 100644 --- a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs @@ -184,6 +184,61 @@ namespace Vanguard.Tests } } + [Test] + public void Trigger_Zone_Uses_Local_Plus_And_Minus_Ninety_Degrees() + { + var config = PlaymatLayoutConfig.CreateDefault(); + var board = GameBoard3D.Create(null, config); + try + { + board.SetVisible(true); + var view = BuildView(you: 0); + var self = view.Players.Single(player => player.Index == 0); + var opponent = view.Players.Single(player => player.Index == 1); + self.Trigger = new List + { + new CardView + { + PrintingId = "BT01/002EN", + InstanceId = 31, + FaceUp = true, + }, + }; + opponent.Trigger = new List + { + new CardView + { + PrintingId = "BT01/003EN", + InstanceId = 32, + FaceUp = true, + }, + }; + + board.Render(view, new BoardRenderState()); + + var own = ActiveCards().Single(card => + card.Ref.Zone == BoardZone.Trigger + && card.Ref.Player == 0); + var enemy = ActiveCards().Single(card => + card.Ref.Zone == BoardZone.Trigger + && card.Ref.Player == 1); + var layout = new PlaymatLayout(config); + + Assert.AreEqual( + 90f, + Mathf.DeltaAngle(layout.BaseYaw(0), own.TargetYaw), + 0.1f); + Assert.AreEqual( + -90f, + Mathf.DeltaAngle(layout.BaseYaw(1), enemy.TargetYaw), + 0.1f); + } + finally + { + Object.DestroyImmediate(board.gameObject); + } + } + [Test] public void Pool_Reuses_Cards_Between_Snapshots() { @@ -256,6 +311,47 @@ namespace Vanguard.Tests } } + [Test] + public void Card_Arc_Move_Interpolates_And_Finishes_At_Target() + { + var config = PlaymatLayoutConfig.CreateDefault(); + var root = new GameObject("ArcRoot"); + var card = Card3D.Create(root.transform, config); + try + { + var from = new Vector3(-1f, 0.1f, 0f); + var to = new Vector3(1f, 0.1f, 0f); + card.PlaceImmediate(from, Quaternion.identity, Vector3.one); + card.SetPose( + to, + Quaternion.identity, + true, + false, + false, + scale: 1f); + card.StartArcMove( + from, + Quaternion.identity, + Vector3.one, + 0.5f, + 0.4f); + + card.Tick(0.25f); + Assert.IsTrue(card.IsMoving); + Assert.Greater(card.transform.position.y, from.y); + + card.Tick(0.5f); + Assert.IsFalse(card.IsMoving); + Assert.Less( + Vector3.Distance(card.transform.position, to), + 0.001f); + } + finally + { + Object.DestroyImmediate(root); + } + } + private static List ActiveCards() => Resources.FindObjectsOfTypeAll() .Where(card => card.gameObject.activeInHierarchy) diff --git a/docs/3d-ux-backlog.md b/docs/3d-ux-backlog.md index 7653684..4a85370 100644 --- a/docs/3d-ux-backlog.md +++ b/docs/3d-ux-backlog.md @@ -1,9 +1,25 @@ # 3D UX backlog -Анализ требований по UX-долгам 3D-поля. **Это план доработок, не реализация.** +Анализ требований по UX-долгам 3D-поля и статус реализации. Дата анализа: **2026-10-24**. -## 0. Фактическое состояние +## Статус реализации (2026-10-24) + +- **P0 готово:** `CardMoveAnimator` + дуга/scale/rotation, stagger и + `InputLock`; diff по `InstanceId` с переносом пула; `AnimationConfig` + расширен; ориентация Trigger Zone ±90°; `DropZoneFaceDown`. +- **P0 готово:** `CameraDirector` и `TriggerCheckSequence` (zoom → hold → + return, цепочка, восстановление pose). +- **P1 частично:** `ArrowConfig`, общий `ArrowDirector`, красная attack и + синяя boost-стрелка, grow/arc/dash (UV-tile fallback), якоря у края карт. +- **P1 частично:** multi-target Counter Blast доведён до сервера + (`DamageIndexes` в `ActivateAbilityIntent`) и клиентского выбора; overlay + заменён счётчиком в панели «Действия» (по правилам проекта). +- **Не сделано:** multi-target Soul Blast и другие типы стоимости, + `EffectConfig`, отдельный `CostPaymentOverlay`, `AttackResolutionQueue`, + world-space Power/Shield, world-space dash shader. + +## 0. Состояние до реализации (историческая часть) Промпт предполагает наличие `CardMoveAnimator`, `CameraDirector`, `TriggerCheckSequence`, `OnCardMoved`/`OnTargetChanged`, `CostPaymentController`, @@ -221,16 +237,19 @@ deck / Drop (если faceDown) показывают рубашку; flip про 5. **P2**: World-Space Power/Shield, удаление/скрытие `CombatStats` при необходимости, polish и профилирование. -## 8. Открытые решения перед кодом +## 8. Принятые решения -- UniTask или Coroutine (в проекте нет ни DOTween, ни UniTask). -- `DropZoneFaceDown` default: `false` по Classic, но нужен ли рубашкой вверх - для Drop в текущем UX? -- Dash: world-space shader или сегментированный mesh (рекомендация — - shader, fallback — mesh). -- Судьба верхнего `CombatStats`: скрыть/оставить debug/перенести к картам. -- Протокол multi-target cost: новый `CostPaymentIntent` или расширение - `ActivateAbilityIntent` массивами (влияет на `Vanguard.Shared` и - обратную совместимость). -- Камера: кто владеет `Camera` во время trigger sequence и как это - сочетается с `EnsureCamera()`. +- **Асинхронность:** без новых пакетов; `CardMoveAnimator` и + `TriggerCheckSequence` работают через `Tick`/корутинный эквивалент. +- **Drop:** `DropZoneFaceDown = false` по Classic; флаг доступен в + `PlaymatLayoutConfig`. +- **Dash:** UV-tile fallback (`LineTextureMode.Tile` + прокрутка offset); + world-space shader оставлен как будущая замена. +- **`CombatStats`:** верхний strip пока оставлен как HUD; world-space + Power/Shield — P2. +- **Multi-target cost:** расширен `ActivateAbilityIntent.DamageIndexes` + (`[Key(3)]`, обратная совместимость); `CounterBlast` принимает массив. + `Soul Blast` и остальные типы — следующий шаг. +- **Камера:** владелец — `CameraDirector`; `EnsureCamera()` не перезаписывает + pose, пока `CameraDirector.IsActive`. `TriggerCheckSequence` снимает + `InputLock` перед выбором цели и возвращает камеру после hold. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 19f74f2..fd92aeb 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1091,3 +1091,19 @@ Battle; Boot и общие сервисы остаются персистент кнопок выбора в боковой панели нет. - Проверки: сервер **144** правил + **45** серверных, `validate` и `translations check` OK; клиент EditMode **36/36**. + +## 2026-10-24 — 3D UX P0/P1 + +- Добавлены `CardMoveAnimator` (дуга, stagger, InputLock, diff по InstanceId) + и расширенный `AnimationConfig`; новые карты/переходы больше не телепортятся. +- Добавлены `CameraDirector` и `TriggerCheckSequence`: zoom → reveal → return, + восстановление исходной камеры, владение камерой во время цепочки. +- Trigger Zone: своя +90°, противника −90° относительно базы; Drop получил + `DropZoneFaceDown`. +- `ArrowDirector`/`ArrowConfig`: attack/boost стрелки, grow/arc/dash, + наконечники у края карт. +- Multi-target Counter Blast: `DamageIndexes` в `ActivateAbilityIntent` и + `GameEngine.CounterBlast`; клиентский выбор нескольких карт урона и счётчик + в панели «Действия». +- Проверки: правила 151, сервер 45, клиентские EditMode 40/40; `validate`, + `translations check`, standalone build; сервер задеплоен. \ No newline at end of file diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index cd69c85..5a4070a 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -1,6 +1,6 @@ # Vanguard ReMaster — состояние сессии -**Дата:** 2026-10-23 +**Дата:** 2026-10-24 **HEAD:** см. `git log -1` **Коротко:** сервер готов, клиент — рабочий каркас с 3D-сценами. @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **149** правил + **45** серверных, CI зелёный. +- Тесты: **151** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Диагностика Ride в `MatchGrain`: входящие интенты, результат, события, снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`. @@ -159,6 +159,19 @@ обновляются на каждом снимке. Зависшие причины `InputLockService` снимаются, когда reconnect/overlay/ability/card-menu/feedback уже неактивны; `board`-лок больше не накапливается на каждом снапшоте. +- 3D UX (P0/P1): `CardMoveAnimator` переносит карты по дуге с + stagger/`InputLock`; diff идёт по `InstanceId`, пул сохраняет карту при + смене зоны. `AnimationConfig` расширен (`moveDuration`, + `importantMoveDuration`, `moveDelayBetweenCards`, `arcHeight`, + camera/grow/dash). Trigger Zone: своя +90°, противника −90° относительно + базы; `DropZoneFaceDown` в конфиге. `CameraDirector` + + `TriggerCheckSequence`: zoom → hold → return, цепочка, точный Restore; + `EnsureCamera` не перезаписывает камеру во время цепочки. + `ArrowDirector`: красная attack и синяя boost-стрелка, grow/arc/dash + (UV-tile), якоря у края карт. Multi-target Counter Blast: выбранные карты + урона уходят массивом `DamageIndexes`, сервер снимает именно их; счётчик + выбора показывается в панели «Действия». Детали — + `docs/3d-ux-backlog.md`. - Опциональные AUTO-способности (Barcgal после рейда Marron) открывают модальный оверлей «Способность» с «Активировать»/«Пропустить»; в боковой панели этих кнопок нет. После подтверждения призыва сервер предлагает @@ -204,8 +217,10 @@ открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). - Не готово: 3D-модели карт/стола (пока процедурные примитивы и арты), звук, настройки (язык уже есть), DOTween, drag&drop и live-валидация - декбилдера. Камера боя фиксирована; анимации retire/ghost для - 3D-карт ещё не перенесены. + декбилдера. Камера боя в покое фиксирована, но `CameraDirector` временно + ведёт trigger-последовательность. Не перенесены отдельные retire/ghost + анимации, `AttackResolutionQueue`, multi-target Soul Blast, world-space + Power/Shield. ## Запуск @@ -255,7 +270,7 @@ Standalone Windows: -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **38/38** EditMode (сборка всех экранов, импорт арта, +Клиентские проверки: **40/40** EditMode (сборка всех экранов, импорт арта, перевод статических/динамических строк, live-переключение языка, рендер 3D-доски с уроном и отдыхающей картой, геометрия поля, правила перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, @@ -338,10 +353,10 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack ## Следующие шаги -1. UX-план 3D-поля: Phase 2–4 закрыты, overlay-экраны вынесены в prefab - (`Resources/Overlays`), раскладка проверена на 1920×1080 и масштабируется - CanvasScaler'ом. Осталось при желании отполировать позиции на конкретном - мониторе. +1. 3D UX: P0 (движение, ориентации, камера/триггер) и базовый P1 (стрелки, + multi-target Counter Blast) готовы. Осталось: `AttackResolutionQueue`, + multi-target Soul Blast/прочие стоимости, world-space Power/Shield, + world-space dash shader; полировка позиций на конкретном мониторе. 2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты. 3. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost. 4. Звук и настройки, DOTween. diff --git a/docs/protocol.md b/docs/protocol.md index e2a3309..e5c258f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -169,7 +169,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм | 12 | `SelectTriggerIntent` | `index` | | 13 | `ResolveAbilityIntent` | `accept`, `damageIndex?` | | 14 | `ChooseIntent` | `index` | -| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?` | +| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?`, `damageIndexes?` | | 16 | `EndTurnIntent` | — | | 17 | `ConcedeIntent` | — | diff --git a/server/src/Vanguard.Rules/Engine/GameCommands.cs b/server/src/Vanguard.Rules/Engine/GameCommands.cs index 2878b87..abdf75e 100644 --- a/server/src/Vanguard.Rules/Engine/GameCommands.cs +++ b/server/src/Vanguard.Rules/Engine/GameCommands.cs @@ -20,7 +20,8 @@ public sealed record ChooseCommand(int Index) : GameCommand; public sealed record ActivateAbilityCommand( int InstanceId, string? DiscardPrintingId = null, - int DamageIndex = -1) : GameCommand; + int DamageIndex = -1, + int[]? DamageIndexes = null) : GameCommand; public sealed record EndTurnCommand : GameCommand; public sealed record CommandResult(bool Accepted, string? Reason) diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index 3f23651..9c5a9d5 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -75,6 +75,7 @@ public sealed class GameEngine /// Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь. private int _resolvingDepth; private int _counterBlastDisplayIndex = -1; + private int[]? _counterBlastDisplayIndexes; private readonly bool _enforceFirstTurnAttackRule; /// @@ -336,7 +337,11 @@ public sealed class GameEngine } } - private CommandResult Activate(int instanceId, string? discardPrintingId, int damageIndex) + private CommandResult Activate( + int instanceId, + string? discardPrintingId, + int damageIndex, + int[]? damageIndexes) { if (State.Phase != Phase.Main) return CommandResult.Reject("Активируемые способности доступны только в Основной фазе."); @@ -347,6 +352,17 @@ public sealed class GameEngine var player = State.ActivePlayer; var p = State.Players[player]; + if (damageIndexes is { Length: > 0 }) + { + var faceUp = FaceUpDamage(player); + if (damageIndexes.Any(index => index < 0 || index >= faceUp) + || damageIndexes.Distinct().Count() != damageIndexes.Length) + { + return CommandResult.Reject( + "Выбранные карты Counter Blast недоступны."); + } + } + var unit = AllUnits(player).FirstOrDefault(u => u.Id == instanceId); var fromHand = false; var fromDrop = false; @@ -439,6 +455,7 @@ public sealed class GameEngine } _counterBlastDisplayIndex = damageIndex; + _counterBlastDisplayIndexes = damageIndexes; try { ability.Resolve(context); @@ -446,6 +463,7 @@ public sealed class GameEngine finally { _counterBlastDisplayIndex = -1; + _counterBlastDisplayIndexes = null; } _events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true)); @@ -734,9 +752,28 @@ public sealed class GameEngine // Клиент передаёт индекс в том же порядке, в котором рисует урон: // сначала закрытые, затем открытые карты. - if (_counterBlastDisplayIndex >= 0) + var order = DamageDisplayOrder(player); + var selected = _counterBlastDisplayIndexes; + if (selected is { Length: > 0 }) + { + foreach (var displayIndex in selected) + { + if (flipped >= amount) + break; + + if (displayIndex < 0 || displayIndex >= order.Count) + continue; + + var chosen = order[displayIndex].Card; + if (!chosen.FaceUp) + continue; + + chosen.FaceUp = false; + flipped++; + } + } + else if (_counterBlastDisplayIndex >= 0) { - var order = DamageDisplayOrder(player); if (_counterBlastDisplayIndex < order.Count) { var chosen = order[_counterBlastDisplayIndex].Card; @@ -750,6 +787,8 @@ public sealed class GameEngine _counterBlastDisplayIndex = -1; } + _counterBlastDisplayIndexes = null; + foreach (var card in damage) { if (flipped >= amount) @@ -758,6 +797,15 @@ public sealed class GameEngine if (!card.FaceUp) continue; + if (selected is { Length: > 0 } + && selected.Any(index => + index >= 0 + && index < order.Count + && ReferenceEquals(order[index].Card, card))) + { + continue; + } + card.FaceUp = false; flipped++; } @@ -1783,7 +1831,11 @@ public sealed class GameEngine SelectTriggerCommand c => SelectTrigger(c.Index), ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex), ChooseCommand c => Choose(c.Index), - ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId, c.DamageIndex), + ActivateAbilityCommand c => Activate( + c.InstanceId, + c.DiscardPrintingId, + c.DamageIndex, + c.DamageIndexes), EndTurnCommand => EndTurn(), _ => CommandResult.Reject("Неизвестная команда."), }; diff --git a/server/src/Vanguard.Rules/Replay/CommandCodec.cs b/server/src/Vanguard.Rules/Replay/CommandCodec.cs index 0b4a071..047869b 100644 --- a/server/src/Vanguard.Rules/Replay/CommandCodec.cs +++ b/server/src/Vanguard.Rules/Replay/CommandCodec.cs @@ -28,7 +28,7 @@ public static class CommandCodec SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}", ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}{Separator}{c.DamageIndex}", ChooseCommand c => $"choose{Separator}{c.Index}", - ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}{Separator}{c.DamageIndex}", + ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}{Separator}{c.DamageIndex}{Separator}{(c.DamageIndexes == null ? string.Empty : string.Join(',', c.DamageIndexes))}", EndTurnCommand => "endTurn", _ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."), }; @@ -63,7 +63,10 @@ public static class CommandCodec "activate" => new ActivateAbilityCommand( ParseInt(parts[1]), Nullable(parts[2]), - parts.Length > 3 ? ParseInt(parts[3]) : -1), + parts.Length > 3 ? ParseInt(parts[3]) : -1, + parts.Length > 4 && parts[4].Length > 0 + ? parts[4].Split(',').Select(ParseInt).ToArray() + : null), "endTurn" => new EndTurnCommand(), _ => throw new FormatException($"Неизвестная команда: {encoded}"), }; diff --git a/server/src/Vanguard.Shared/Projection/IntentMapping.cs b/server/src/Vanguard.Shared/Projection/IntentMapping.cs index 9f9d20a..aca88f4 100644 --- a/server/src/Vanguard.Shared/Projection/IntentMapping.cs +++ b/server/src/Vanguard.Shared/Projection/IntentMapping.cs @@ -48,6 +48,7 @@ public static class IntentMapping InstanceId = c.InstanceId, DiscardPrintingId = c.DiscardPrintingId, DamageIndex = c.DamageIndex, + DamageIndexes = c.DamageIndexes ?? System.Array.Empty(), }, EndTurnCommand => new EndTurnIntent(), _ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."), @@ -73,7 +74,11 @@ public static class IntentMapping SelectTriggerIntent c => new SelectTriggerCommand(c.Index), ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept, c.DamageIndex), ChooseIntent c => new ChooseCommand(c.Index), - ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId, c.DamageIndex), + ActivateAbilityIntent c => new ActivateAbilityCommand( + c.InstanceId, + c.DiscardPrintingId, + c.DamageIndex, + c.DamageIndexes), EndTurnIntent => new EndTurnCommand(), ConcedeIntent => null, _ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."), diff --git a/server/src/Vanguard.Shared/Protocol/Intents.cs b/server/src/Vanguard.Shared/Protocol/Intents.cs index d248adc..41e16d5 100644 --- a/server/src/Vanguard.Shared/Protocol/Intents.cs +++ b/server/src/Vanguard.Shared/Protocol/Intents.cs @@ -138,6 +138,12 @@ public sealed class ActivateAbilityIntent : IntentPayload /// Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто. [Key(2)] public int DamageIndex { get; set; } = -1; + + /// + /// Все выбранные карты урона для multi-target Counter Blast. + /// Пустой массив означает старый одиночный DamageIndex. + /// + [Key(3)] public int[] DamageIndexes { get; set; } = System.Array.Empty(); } [MessagePackObject] diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index d6ddfe4..f59f81e 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Vanguard.Rules.Cards; using Vanguard.Rules.Decks; using Vanguard.Rules.Engine; @@ -231,6 +231,46 @@ public class GameEngineTests Assert.False(second.FaceUp); } + [Fact] + public void CounterBlast_Can_Use_Multiple_Selected_Damage_Cards() + { + var engine = InMainPhase(); + var gancelot = MakeCard(Gancelot); + engine.State.Players[0].Vanguard = gancelot; + engine.State.Players[0].Soul.Add(MakeCard(BlasterBladePrint)); + GiveDamage(engine, 0, 3); + + var first = engine.State.Players[0].Damage[0]; + var middle = engine.State.Players[0].Damage[1]; + var last = engine.State.Players[0].Damage[2]; + + var result = engine.Execute(new ActivateAbilityCommand( + gancelot.Id, + null, + -1, + new[] { 0, 2 })); + Assert.True(result.Accepted, result.Reason); + + Assert.False(first.FaceUp); + Assert.True(middle.FaceUp); + Assert.False(last.FaceUp); + } + + [Fact] + public void CounterBlast_Rejects_Invalid_Multi_Selection() + { + var engine = InMainPhase(); + var overlord = MakeCard(Overlord); + engine.State.Players[0].Vanguard = overlord; + GiveDamage(engine, 0, 2); + + Assert.False(engine.Execute(new ActivateAbilityCommand( + overlord.Id, + null, + -1, + new[] { 0, 2 })).Accepted); + } + [Fact] public void Called_RearGuard_Enters_Standing_And_Replaces_Occupied_Circle() { @@ -2459,4 +2499,4 @@ public class GameEngineTests /// Карта, которой нет в тестовой колоде: кладём в руку или на поле вручную. private static CardInstance MakeCard(string printingId) => new() { Id = Interlocked.Increment(ref _nextInstanceId), PrintingId = printingId }; -} +} \ No newline at end of file