feat: implement 3D UX movement, trigger camera, arrows, multi counter blast
CI / test (push) Successful in 1m52s Details

This commit is contained in:
MpakobecEx 2026-10-24 02:05:58 +05:00
parent 083357f370
commit 3c7f8645b2
30 changed files with 1569 additions and 87 deletions

View File

@ -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);

View File

@ -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<ArrowConfig>();
_default.name = "ArrowConfig (runtime)";
_default.hideFlags = HideFlags.DontSave;
}
return _default;
}
}
public static ArrowConfig LoadOrDefault()
{
var loaded = Resources.Load<ArrowConfig>("ArrowConfig");
return loaded != null ? loaded : Default;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6a3701087bff10942a9c17bee301638b

View File

@ -0,0 +1,143 @@
using UnityEngine;
namespace Vanguard.Battle
{
/// <summary>
/// Единственный владелец кинематографичного движения камеры.
/// Снимок исходного pose берётся один раз на цепочку и
/// восстанавливается идемпотентно.
/// </summary>
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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 131f7624a241a2140b5ed6b9bc2ff0a5

View File

@ -0,0 +1,153 @@
using UnityEngine;
using Vanguard.Battle;
namespace Vanguard.Battle
{
/// <summary>
/// Оркестратор кинематографичной проверки триггера:
/// zoom → reveal hold → return; повторный zoom в цепочке.
/// </summary>
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();
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,135 @@
using System.Collections.Generic;
using UnityEngine;
using Vanguard.Battle;
namespace Vanguard.Board3D
{
/// <summary>
/// Единое управление красной attack-стрелкой и синими boost-стрелками.
/// Геометрия/анимация общая, различие — цвет и толщина.
/// </summary>
public sealed class ArrowDirector
{
private readonly Transform _parent;
private AnimationConfig _animation;
private ArrowConfig _config;
private AttackArrowView _attack;
private readonly List<AttackArrowView> _boosts =
new List<AttackArrowView>();
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);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 509a80c7422e7dc4cba2cdc07c47177a

View File

@ -0,0 +1,150 @@
using System.Collections.Generic;
using UnityEngine;
using Vanguard.Battle;
using Vanguard.Board3D;
namespace Vanguard.Board3D
{
/// <summary>
/// Единый планировщик карточных перемещений. Карточки анимируются
/// Card3D, аниматор отвечает за stagger, очередь и InputLock.
/// </summary>
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<Request> _queue = new Queue<Request>();
private readonly List<Card3D> _active = new List<Card3D>();
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;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 783f38d9ba797aa4a93d7d48752d83bb

View File

@ -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-столе.")]

View File

@ -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<int> AbilityDamageIndexes { get; } = new HashSet<int>();
public bool MatchFinished { get; set; }
}

View File

@ -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)

View File

@ -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();

View File

@ -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<string, Card3D> _cards =
new Dictionary<string, Card3D>(StringComparer.Ordinal);
private readonly Stack<Card3D> _pool = new Stack<Card3D>();
private CardMoveAnimator _moveAnimator;
private CameraDirector _cameraDirector;
private readonly List<Pile3D> _piles = new List<Pile3D>();
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<CardSpec>(40);
BuildSpecs(view, state, specs);
_moveAnimator ??= new CardMoveAnimator(
_inputLock,
_animationConfig != null
? _animationConfig
: AnimationConfig.Default);
var previousByInstance = new Dictionary<int, Card3D>();
var previousKeyByInstance = new Dictionary<int, string>();
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<string>(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)

View File

@ -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<string, CardCatalogItemDto> _catalog =
@ -106,6 +107,7 @@ namespace Vanguard.UI
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
private bool _mulliganUsed;
private int _abilityDamageIndex = -1;
private readonly HashSet<int> _abilityDamageIndexes = new HashSet<int>();
private readonly HashSet<BoardSlotKey> _highlightSlots = new HashSet<BoardSlotKey>();
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();
}

View File

@ -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<CardView>
{
new CardView
{
PrintingId = "BT01/002EN",
InstanceId = 31,
FaceUp = true,
},
};
opponent.Trigger = new List<CardView>
{
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<Card3D> ActiveCards() =>
Resources.FindObjectsOfTypeAll<Card3D>()
.Where(card => card.gameObject.activeInHierarchy)

View File

@ -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.

View File

@ -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; сервер задеплоен.

View File

@ -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.

View File

@ -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` | — |

View File

@ -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)

View File

@ -75,6 +75,7 @@ public sealed class GameEngine
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
private int _resolvingDepth;
private int _counterBlastDisplayIndex = -1;
private int[]? _counterBlastDisplayIndexes;
private readonly bool _enforceFirstTurnAttackRule;
/// <summary>
@ -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("Неизвестная команда."),
};

View File

@ -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}"),
};

View File

@ -48,6 +48,7 @@ public static class IntentMapping
InstanceId = c.InstanceId,
DiscardPrintingId = c.DiscardPrintingId,
DamageIndex = c.DamageIndex,
DamageIndexes = c.DamageIndexes ?? System.Array.Empty<int>(),
},
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, "Неизвестный интент."),

View File

@ -138,6 +138,12 @@ public sealed class ActivateAbilityIntent : IntentPayload
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
[Key(2)] public int DamageIndex { get; set; } = -1;
/// <summary>
/// Все выбранные карты урона для multi-target Counter Blast.
/// Пустой массив означает старый одиночный DamageIndex.
/// </summary>
[Key(3)] public int[] DamageIndexes { get; set; } = System.Array.Empty<int>();
}
[MessagePackObject]

View File

@ -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()
{