feat: implement 3D UX movement, trigger camera, arrows, multi counter blast
CI / test (push) Successful in 1m52s
Details
CI / test (push) Successful in 1m52s
Details
This commit is contained in:
parent
083357f370
commit
3c7f8645b2
Binary file not shown.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6a3701087bff10942a9c17bee301638b
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 131f7624a241a2140b5ed6b9bc2ff0a5
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c2a978244b3921c4898d2446f192e677
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8590de763c9f81d40b1acc931836a9ac
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 509a80c7422e7dc4cba2cdc07c47177a
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 783f38d9ba797aa4a93d7d48752d83bb
|
||||
|
|
@ -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-столе.")]
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
card = RentCard();
|
||||
_cards[spec.Key] = card;
|
||||
if (_cards.TryGetValue(spec.Key, out var displaced)
|
||||
&& displaced != null
|
||||
&& displaced != previous)
|
||||
{
|
||||
ReleaseCard(displaced);
|
||||
}
|
||||
|
||||
var changed = !card.gameObject.activeSelf
|
||||
|| card.Ref.InstanceId != spec.Ref.InstanceId
|
||||
|| card.Ref.Zone != spec.Ref.Zone;
|
||||
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 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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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; сервер задеплоен.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` | — |
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
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("Неизвестная команда."),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, "Неизвестный интент."),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue