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 callMoveDuration = 0.55f;
|
||||||
public float retireDuration = 0.8f;
|
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("Бой")]
|
[Header("Бой")]
|
||||||
public float attackDeclare = 1.2f;
|
public float attackDeclare = 1.2f;
|
||||||
public float guard = 1.9f;
|
public float guard = 1.9f;
|
||||||
|
|
@ -66,6 +85,19 @@ namespace Vanguard.Battle
|
||||||
cardFlipDuration = Mathf.Max(0.05f, cardFlipDuration);
|
cardFlipDuration = Mathf.Max(0.05f, cardFlipDuration);
|
||||||
callMoveDuration = Mathf.Max(0.05f, callMoveDuration);
|
callMoveDuration = Mathf.Max(0.05f, callMoveDuration);
|
||||||
retireDuration = Mathf.Max(0.05f, retireDuration);
|
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);
|
attackDeclare = Mathf.Max(0.05f, attackDeclare);
|
||||||
guard = Mathf.Max(0.05f, guard);
|
guard = Mathf.Max(0.05f, guard);
|
||||||
driveCheck = Mathf.Max(0.05f, driveCheck);
|
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;
|
public float damageX = 3.0f;
|
||||||
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
|
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
|
||||||
public float pileX = 2.95f;
|
public float pileX = 2.95f;
|
||||||
|
[Tooltip("Показывать Drop рубашкой вверх. По Classic — false.")]
|
||||||
|
public bool dropZoneFaceDown = false;
|
||||||
|
|
||||||
[Header("Отладка")]
|
[Header("Отладка")]
|
||||||
[Tooltip("Показывать подписи зон на 3D-столе.")]
|
[Tooltip("Показывать подписи зон на 3D-столе.")]
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ namespace Vanguard.Board3D
|
||||||
public bool AbilityDamageTargeting { get; set; }
|
public bool AbilityDamageTargeting { get; set; }
|
||||||
public int AbilityDamagePlayer { get; set; } = -1;
|
public int AbilityDamagePlayer { get; set; } = -1;
|
||||||
public int AbilityDamageIndex { get; set; } = -1;
|
public int AbilityDamageIndex { get; set; } = -1;
|
||||||
|
public HashSet<int> AbilityDamageIndexes { get; } = new HashSet<int>();
|
||||||
public bool MatchFinished { get; set; }
|
public bool MatchFinished { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,19 @@ namespace Vanguard.Board3D
|
||||||
private LineRenderer _shaft;
|
private LineRenderer _shaft;
|
||||||
private LineRenderer _head;
|
private LineRenderer _head;
|
||||||
private Material _material;
|
private Material _material;
|
||||||
|
private Texture2D _dashTexture;
|
||||||
private AnimationConfig _config;
|
private AnimationConfig _config;
|
||||||
private Vector3 _from;
|
private Vector3 _from;
|
||||||
private Vector3 _to;
|
private Vector3 _to;
|
||||||
private bool _visible;
|
private bool _visible;
|
||||||
private float _alpha;
|
private float _alpha;
|
||||||
private float _widthScale = 1f;
|
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;
|
public bool IsVisible => _visible || _alpha > 0.001f;
|
||||||
|
|
||||||
|
|
@ -37,6 +44,13 @@ namespace Vanguard.Board3D
|
||||||
shader = Shader.Find("Unlit/Color");
|
shader = Shader.Find("Unlit/Color");
|
||||||
|
|
||||||
_material = shader != null ? new Material(shader) : null;
|
_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);
|
_shaft = CreateLine("Shaft", ShaftWidth);
|
||||||
_head = CreateLine("Head", ShaftWidth * 0.7f);
|
_head = CreateLine("Head", ShaftWidth * 0.7f);
|
||||||
_head.positionCount = 3;
|
_head.positionCount = 3;
|
||||||
|
|
@ -48,10 +62,29 @@ namespace Vanguard.Board3D
|
||||||
_config = config;
|
_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)
|
public void Show(Vector3 from, Vector3 to)
|
||||||
{
|
{
|
||||||
SetPoints(from, to);
|
SetPoints(from, to);
|
||||||
_visible = true;
|
if (!_visible)
|
||||||
|
{
|
||||||
|
_grow = 0f;
|
||||||
|
_visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (_root != null)
|
if (_root != null)
|
||||||
_root.SetActive(true);
|
_root.SetActive(true);
|
||||||
|
|
@ -82,10 +115,16 @@ namespace Vanguard.Board3D
|
||||||
if (_root == null)
|
if (_root == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_visible && _grow < 1f)
|
||||||
|
_grow = Mathf.MoveTowards(
|
||||||
|
_grow,
|
||||||
|
1f,
|
||||||
|
deltaTime / _growDuration);
|
||||||
|
|
||||||
var target = _visible ? 1f : 0f;
|
var target = _visible ? 1f : 0f;
|
||||||
var duration = _visible
|
var duration = _visible
|
||||||
? (_config != null ? _config.arrowShowDuration : 0.25f)
|
? (_config != null ? _config.arrowShowDuration : 0.25f)
|
||||||
: (_config != null ? _config.arrowHideDuration : 0.2f);
|
: _fadeDuration;
|
||||||
_alpha = Mathf.MoveTowards(
|
_alpha = Mathf.MoveTowards(
|
||||||
_alpha,
|
_alpha,
|
||||||
target,
|
target,
|
||||||
|
|
@ -99,6 +138,13 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
_root.SetActive(true);
|
_root.SetActive(true);
|
||||||
_widthScale = 0.55f + (_alpha * 0.45f);
|
_widthScale = 0.55f + (_alpha * 0.45f);
|
||||||
|
if (_material != null && _config != null)
|
||||||
|
{
|
||||||
|
var offset = _material.mainTextureOffset;
|
||||||
|
offset.x += _config.dashScrollSpeed * deltaTime;
|
||||||
|
_material.mainTextureOffset = offset;
|
||||||
|
}
|
||||||
|
|
||||||
UpdateGeometry();
|
UpdateGeometry();
|
||||||
ApplyColor();
|
ApplyColor();
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +160,15 @@ namespace Vanguard.Board3D
|
||||||
_root = null;
|
_root = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_dashTexture != null)
|
||||||
|
{
|
||||||
|
if (Application.isPlaying)
|
||||||
|
Object.Destroy(_dashTexture);
|
||||||
|
else
|
||||||
|
Object.DestroyImmediate(_dashTexture);
|
||||||
|
_dashTexture = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (_material != null)
|
if (_material != null)
|
||||||
{
|
{
|
||||||
if (Application.isPlaying)
|
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)
|
private LineRenderer CreateLine(string name, float width)
|
||||||
{
|
{
|
||||||
var go = new GameObject(name);
|
var go = new GameObject(name);
|
||||||
|
|
@ -135,11 +215,12 @@ namespace Vanguard.Board3D
|
||||||
line.numCapVertices = 4;
|
line.numCapVertices = 4;
|
||||||
line.numCornerVertices = 4;
|
line.numCornerVertices = 4;
|
||||||
line.alignment = LineAlignment.View;
|
line.alignment = LineAlignment.View;
|
||||||
line.textureMode = LineTextureMode.Stretch;
|
line.textureMode = LineTextureMode.Tile;
|
||||||
line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||||
line.receiveShadows = false;
|
line.receiveShadows = false;
|
||||||
if (_material != null)
|
if (_material != null)
|
||||||
line.material = _material;
|
line.sharedMaterial = _material;
|
||||||
|
|
||||||
return line;
|
return line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,30 +229,41 @@ namespace Vanguard.Board3D
|
||||||
if (_shaft == null || _head == null)
|
if (_shaft == null || _head == null)
|
||||||
return;
|
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)
|
if (direction.sqrMagnitude < 0.0001f)
|
||||||
direction = Vector3.forward;
|
direction = Vector3.forward;
|
||||||
direction.Normalize();
|
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(0, _from);
|
||||||
_shaft.SetPosition(1, _to);
|
_shaft.SetPosition(1, mid);
|
||||||
_shaft.widthMultiplier = ShaftWidth * _widthScale;
|
_shaft.SetPosition(2, end);
|
||||||
|
_shaft.widthMultiplier = _thickness * _widthScale;
|
||||||
|
|
||||||
var tip = _to;
|
var tip = end;
|
||||||
var back = _to - (direction * HeadLength);
|
var back = tip - (direction * 0.32f);
|
||||||
var side = Vector3.Cross(Vector3.up, direction).normalized;
|
var side = Vector3.Cross(Vector3.up, direction).normalized;
|
||||||
if (side.sqrMagnitude < 0.0001f)
|
if (side.sqrMagnitude < 0.0001f)
|
||||||
side = Vector3.right;
|
side = Vector3.right;
|
||||||
var half = side * (HeadWidth * _widthScale);
|
var half = side * (0.16f * _widthScale);
|
||||||
_head.SetPosition(0, back + half);
|
_head.SetPosition(0, back + half);
|
||||||
_head.SetPosition(1, tip);
|
_head.SetPosition(1, tip);
|
||||||
_head.SetPosition(2, back - half);
|
_head.SetPosition(2, back - half);
|
||||||
_head.widthMultiplier = ShaftWidth * 0.7f * _widthScale;
|
_head.widthMultiplier = _thickness * 0.7f * _widthScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyColor()
|
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 })
|
foreach (var line in new[] { _shaft, _head })
|
||||||
{
|
{
|
||||||
if (line == null)
|
if (line == null)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ namespace Vanguard.Board3D
|
||||||
private Vector3 _baseScale = Vector3.one;
|
private Vector3 _baseScale = Vector3.one;
|
||||||
private bool _immediate;
|
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 bool _dragging;
|
||||||
private Vector3 _dragOriginPosition;
|
private Vector3 _dragOriginPosition;
|
||||||
private Quaternion _dragOriginRotation;
|
private Quaternion _dragOriginRotation;
|
||||||
|
|
@ -42,9 +50,11 @@ namespace Vanguard.Board3D
|
||||||
public Quaternion TargetRotation => _targetRotation;
|
public Quaternion TargetRotation => _targetRotation;
|
||||||
public bool IsAnimating =>
|
public bool IsAnimating =>
|
||||||
!_dragging
|
!_dragging
|
||||||
&& (Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|
&& (_arcMoving
|
||||||
|
|| Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|
||||||
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|
||||||
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
|
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
|
||||||
|
public bool IsMoving => _arcMoving;
|
||||||
public string StatsText => _stats != null && _stats.gameObject.activeSelf
|
public string StatsText => _stats != null && _stats.gameObject.activeSelf
|
||||||
? _stats.text
|
? _stats.text
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -223,6 +233,64 @@ namespace Vanguard.Board3D
|
||||||
Snap();
|
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)
|
public void SetVisible(bool visible)
|
||||||
{
|
{
|
||||||
gameObject.SetActive(visible);
|
gameObject.SetActive(visible);
|
||||||
|
|
@ -296,6 +364,7 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
public void Snap()
|
public void Snap()
|
||||||
{
|
{
|
||||||
|
_arcMoving = false;
|
||||||
transform.position = _targetPosition;
|
transform.position = _targetPosition;
|
||||||
transform.rotation = _targetRotation;
|
transform.rotation = _targetRotation;
|
||||||
transform.localScale = _targetScale;
|
transform.localScale = _targetScale;
|
||||||
|
|
@ -307,6 +376,31 @@ namespace Vanguard.Board3D
|
||||||
if (_dragging || deltaTime <= 0f)
|
if (_dragging || deltaTime <= 0f)
|
||||||
return;
|
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)
|
if (_immediate)
|
||||||
{
|
{
|
||||||
Snap();
|
Snap();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
@ -25,6 +25,8 @@ namespace Vanguard.Board3D
|
||||||
private readonly Dictionary<string, Card3D> _cards =
|
private readonly Dictionary<string, Card3D> _cards =
|
||||||
new Dictionary<string, Card3D>(StringComparer.Ordinal);
|
new Dictionary<string, Card3D>(StringComparer.Ordinal);
|
||||||
private readonly Stack<Card3D> _pool = new Stack<Card3D>();
|
private readonly Stack<Card3D> _pool = new Stack<Card3D>();
|
||||||
|
private CardMoveAnimator _moveAnimator;
|
||||||
|
private CameraDirector _cameraDirector;
|
||||||
private readonly List<Pile3D> _piles = new List<Pile3D>();
|
private readonly List<Pile3D> _piles = new List<Pile3D>();
|
||||||
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
|
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
|
||||||
private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
|
private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
|
||||||
|
|
@ -53,7 +55,7 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
private BoardZoneView _guardianZone;
|
private BoardZoneView _guardianZone;
|
||||||
private BoardZoneView _dropIndicator;
|
private BoardZoneView _dropIndicator;
|
||||||
private AttackArrowView _attackArrow;
|
private ArrowDirector _arrowDirector;
|
||||||
private Pile3D _deckSelf;
|
private Pile3D _deckSelf;
|
||||||
private Pile3D _deckOpponent;
|
private Pile3D _deckOpponent;
|
||||||
private Pile3D _dropSelf;
|
private Pile3D _dropSelf;
|
||||||
|
|
@ -80,9 +82,18 @@ namespace Vanguard.Board3D
|
||||||
public PlaymatLayout Layout => _layout;
|
public PlaymatLayout Layout => _layout;
|
||||||
public int SelfPlayer => _selfPlayer;
|
public int SelfPlayer => _selfPlayer;
|
||||||
public bool AttackArrowVisible =>
|
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 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)
|
public bool TryGetCardScreenPosition(BoardCardRef reference, out Vector2 screen)
|
||||||
{
|
{
|
||||||
screen = default;
|
screen = default;
|
||||||
|
|
@ -118,7 +129,13 @@ namespace Vanguard.Board3D
|
||||||
{
|
{
|
||||||
_animationConfig = animationConfig;
|
_animationConfig = animationConfig;
|
||||||
_inputLock = inputLock;
|
_inputLock = inputLock;
|
||||||
_attackArrow?.SetConfig(_animationConfig);
|
_arrowDirector?.SetConfig(
|
||||||
|
_animationConfig,
|
||||||
|
ArrowConfig.LoadOrDefault());
|
||||||
|
_moveAnimator ??= new CardMoveAnimator(
|
||||||
|
_inputLock,
|
||||||
|
_animationConfig);
|
||||||
|
_moveAnimator.SetConfig(_animationConfig);
|
||||||
|
|
||||||
if (_animationConfig == null)
|
if (_animationConfig == null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -181,6 +198,7 @@ namespace Vanguard.Board3D
|
||||||
_cardsRoot.SetParent(transform, false);
|
_cardsRoot.SetParent(transform, false);
|
||||||
|
|
||||||
EnsureCamera();
|
EnsureCamera();
|
||||||
|
_cameraDirector = new CameraDirector(_camera);
|
||||||
SetVisible(false);
|
SetVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,8 +225,9 @@ namespace Vanguard.Board3D
|
||||||
{
|
{
|
||||||
CancelPointer();
|
CancelPointer();
|
||||||
_hasRendered = false;
|
_hasRendered = false;
|
||||||
|
_moveAnimator?.CancelAll(snapCards: false);
|
||||||
_handBindings.Clear();
|
_handBindings.Clear();
|
||||||
_attackArrow?.HideImmediate();
|
_arrowDirector?.HideImmediate();
|
||||||
ReleaseBoardLock();
|
ReleaseBoardLock();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -241,21 +260,74 @@ namespace Vanguard.Board3D
|
||||||
var specs = new List<CardSpec>(40);
|
var specs = new List<CardSpec>(40);
|
||||||
BuildSpecs(view, state, specs);
|
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 used = new HashSet<string>(StringComparer.Ordinal);
|
||||||
var immediateAll = !_hasRendered;
|
var immediateAll = !_hasRendered;
|
||||||
|
|
||||||
foreach (var spec in specs)
|
foreach (var spec in specs)
|
||||||
{
|
{
|
||||||
used.Add(spec.Key);
|
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();
|
card = RentCard();
|
||||||
_cards[spec.Key] = card;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var changed = !card.gameObject.activeSelf
|
_cards[spec.Key] = card;
|
||||||
|| card.Ref.InstanceId != spec.Ref.InstanceId
|
|
||||||
|| card.Ref.Zone != spec.Ref.Zone;
|
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.Bind(spec.Ref);
|
||||||
card.SetArt(spec.Art.Front, spec.Art.Accent);
|
card.SetArt(spec.Art.Front, spec.Art.Accent);
|
||||||
card.SetPose(
|
card.SetPose(
|
||||||
|
|
@ -263,9 +335,64 @@ namespace Vanguard.Board3D
|
||||||
spec.BaseRotation,
|
spec.BaseRotation,
|
||||||
spec.FaceUp,
|
spec.FaceUp,
|
||||||
spec.Rested,
|
spec.Rested,
|
||||||
immediateAll || changed,
|
immediateAll,
|
||||||
spec.DamageLayout,
|
spec.DamageLayout,
|
||||||
spec.Scale * Mathf.Max(0.05f, _config.cardScale));
|
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.SetHighlight(spec.Highlight);
|
||||||
card.SetStats(spec.Stats, spec.StatsModified);
|
card.SetStats(spec.Stats, spec.StatsModified);
|
||||||
|
|
||||||
|
|
@ -309,36 +436,82 @@ namespace Vanguard.Board3D
|
||||||
}
|
}
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
|
_moveAnimator?.CancelAll(snapCards: false);
|
||||||
ClearCards();
|
ClearCards();
|
||||||
_handBindings.Clear();
|
_handBindings.Clear();
|
||||||
_view = null;
|
_view = null;
|
||||||
_hasRendered = false;
|
_hasRendered = false;
|
||||||
_attackArrow?.HideImmediate();
|
_arrowDirector?.HideImmediate();
|
||||||
ReleaseBoardLock();
|
ReleaseBoardLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAttackArrowState(MatchView view)
|
private void UpdateAttackArrowState(MatchView view)
|
||||||
{
|
{
|
||||||
if (_attackArrow == null)
|
if (_arrowDirector == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (view?.Battle == null
|
if (view?.Battle == null
|
||||||
|| !TryResolveAttackPoints(view, out var from, out var to))
|
|| !TryResolveAttackPoints(view, out var from, out var to))
|
||||||
{
|
{
|
||||||
_attackArrow.Hide();
|
_arrowDirector.HideAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_attackArrow.Show(from, to);
|
_arrowDirector.SetAttack(from, to);
|
||||||
|
UpdateBoostArrows(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAttackArrowPoints()
|
private void UpdateAttackArrowPoints()
|
||||||
{
|
{
|
||||||
if (_attackArrow == null || !_attackArrow.IsVisible || _view?.Battle == null)
|
if (_arrowDirector == null || _view?.Battle == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (TryResolveAttackPoints(_view, out var from, out var to))
|
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(
|
private bool TryResolveAttackPoints(
|
||||||
|
|
@ -358,14 +531,16 @@ namespace Vanguard.Board3D
|
||||||
if (attacker == null || defender == null)
|
if (attacker == null || defender == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
from = ArrowAnchor(
|
var attackerCenter = ArrowAnchor(
|
||||||
BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker),
|
BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker),
|
||||||
battle.AttackerPlayer,
|
battle.AttackerPlayer,
|
||||||
battle.AttackerSlot);
|
battle.AttackerSlot);
|
||||||
to = ArrowAnchor(
|
var defenderCenter = ArrowAnchor(
|
||||||
BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender),
|
BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender),
|
||||||
defenderIndex,
|
defenderIndex,
|
||||||
battle.TargetSlot);
|
battle.TargetSlot);
|
||||||
|
from = CardEdge(attackerCenter, defenderCenter);
|
||||||
|
to = CardEdge(defenderCenter, attackerCenter);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,6 +554,20 @@ namespace Vanguard.Board3D
|
||||||
+ (Vector3.up * 0.2f);
|
+ (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)
|
private CardView BattleCard(MatchView view, int playerIndex, int slot)
|
||||||
{
|
{
|
||||||
var player = view?.Players.FirstOrDefault(item => item.Index == playerIndex);
|
var player = view?.Players.FirstOrDefault(item => item.Index == playerIndex);
|
||||||
|
|
@ -615,12 +804,17 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
private void BuildAttackArrow()
|
private void BuildAttackArrow()
|
||||||
{
|
{
|
||||||
_attackArrow = new AttackArrowView();
|
_arrowDirector = new ArrowDirector(
|
||||||
_attackArrow.Build(transform, _animationConfig);
|
transform,
|
||||||
|
_animationConfig,
|
||||||
|
ArrowConfig.LoadOrDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureCamera()
|
private void EnsureCamera()
|
||||||
{
|
{
|
||||||
|
if (_cameraDirector != null && _cameraDirector.IsActive)
|
||||||
|
return;
|
||||||
|
|
||||||
if (_camera == null)
|
if (_camera == null)
|
||||||
_camera = Camera.main;
|
_camera = Camera.main;
|
||||||
if (_camera == null)
|
if (_camera == null)
|
||||||
|
|
@ -794,7 +988,8 @@ namespace Vanguard.Board3D
|
||||||
Position = _layout.TriggerPosition(localSeat, i, count),
|
Position = _layout.TriggerPosition(localSeat, i, count),
|
||||||
BaseRotation = Quaternion.Euler(
|
BaseRotation = Quaternion.Euler(
|
||||||
0f,
|
0f,
|
||||||
_layout.BaseYaw(localSeat),
|
_layout.BaseYaw(localSeat)
|
||||||
|
+ (localSeat == 0 ? 90f : -90f),
|
||||||
0f),
|
0f),
|
||||||
FaceUp = true,
|
FaceUp = true,
|
||||||
Rested = false,
|
Rested = false,
|
||||||
|
|
@ -814,8 +1009,12 @@ namespace Vanguard.Board3D
|
||||||
if (!damageChoice && !abilityCost)
|
if (!damageChoice && !abilityCost)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (abilityCost && state.AbilityDamageIndex == index)
|
if (abilityCost
|
||||||
|
&& (state.AbilityDamageIndex == index
|
||||||
|
|| state.AbilityDamageIndexes.Contains(index)))
|
||||||
|
{
|
||||||
return _config.selectedColor;
|
return _config.selectedColor;
|
||||||
|
}
|
||||||
|
|
||||||
return _config.highlightColor;
|
return _config.highlightColor;
|
||||||
}
|
}
|
||||||
|
|
@ -1009,7 +1208,11 @@ namespace Vanguard.Board3D
|
||||||
: null;
|
: null;
|
||||||
drop.SetCount(
|
drop.SetCount(
|
||||||
player.Drop?.Count ?? 0,
|
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,
|
_config.showZoneLabels ? "DROP" : string.Empty,
|
||||||
new Color(0.30f, 0.30f, 0.40f));
|
new Color(0.30f, 0.30f, 0.40f));
|
||||||
}
|
}
|
||||||
|
|
@ -1064,13 +1267,15 @@ namespace Vanguard.Board3D
|
||||||
UpdateHandView();
|
UpdateHandView();
|
||||||
|
|
||||||
var delta = Time.unscaledDeltaTime;
|
var delta = Time.unscaledDeltaTime;
|
||||||
|
_moveAnimator?.Tick(delta);
|
||||||
|
_cameraDirector?.Tick(delta);
|
||||||
foreach (var card in _cards.Values)
|
foreach (var card in _cards.Values)
|
||||||
{
|
{
|
||||||
if (card != null)
|
if (card != null)
|
||||||
card.Tick(delta);
|
card.Tick(delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
_attackArrow?.Tick(delta);
|
_arrowDirector?.Tick(delta);
|
||||||
UpdateAttackArrowPoints();
|
UpdateAttackArrowPoints();
|
||||||
|
|
||||||
if (_inputLock != null && _inputLock.IsLocked)
|
if (_inputLock != null && _inputLock.IsLocked)
|
||||||
|
|
@ -1454,6 +1659,25 @@ namespace Vanguard.Board3D
|
||||||
ApplySlotHighlights();
|
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)
|
private BoardArt ResolveArt(string printingId)
|
||||||
{
|
{
|
||||||
if (ArtProvider != null)
|
if (ArtProvider != null)
|
||||||
|
|
@ -1510,7 +1734,9 @@ namespace Vanguard.Board3D
|
||||||
if (_instance == this)
|
if (_instance == this)
|
||||||
_instance = null;
|
_instance = null;
|
||||||
|
|
||||||
_attackArrow?.Dispose();
|
_moveAnimator?.CancelAll(snapCards: false);
|
||||||
|
_arrowDirector?.Dispose();
|
||||||
|
_cameraDirector?.Restore();
|
||||||
ReleaseBoardLock();
|
ReleaseBoardLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1525,6 +1751,9 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
private bool AnyCardAnimating()
|
private bool AnyCardAnimating()
|
||||||
{
|
{
|
||||||
|
if (_moveAnimator != null && _moveAnimator.IsBusy)
|
||||||
|
return true;
|
||||||
|
|
||||||
foreach (var card in _cards.Values)
|
foreach (var card in _cards.Values)
|
||||||
{
|
{
|
||||||
if (card != null && card.IsAnimating)
|
if (card != null && card.IsAnimating)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
@ -32,6 +32,7 @@ namespace Vanguard.UI
|
||||||
private readonly Action _onExit;
|
private readonly Action _onExit;
|
||||||
private readonly AnimationConfig _animationConfig;
|
private readonly AnimationConfig _animationConfig;
|
||||||
private readonly InputLockService _inputLock;
|
private readonly InputLockService _inputLock;
|
||||||
|
private TriggerCheckSequence _triggerSequence;
|
||||||
private bool _feedbackLockHeld;
|
private bool _feedbackLockHeld;
|
||||||
private string _lastInputLockSummary;
|
private string _lastInputLockSummary;
|
||||||
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
|
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
|
||||||
|
|
@ -106,6 +107,7 @@ namespace Vanguard.UI
|
||||||
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
|
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
|
||||||
private bool _mulliganUsed;
|
private bool _mulliganUsed;
|
||||||
private int _abilityDamageIndex = -1;
|
private int _abilityDamageIndex = -1;
|
||||||
|
private readonly HashSet<int> _abilityDamageIndexes = new HashSet<int>();
|
||||||
|
|
||||||
private readonly HashSet<BoardSlotKey> _highlightSlots = new HashSet<BoardSlotKey>();
|
private readonly HashSet<BoardSlotKey> _highlightSlots = new HashSet<BoardSlotKey>();
|
||||||
private GameBoard3D _board3D;
|
private GameBoard3D _board3D;
|
||||||
|
|
@ -256,6 +258,9 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
|
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
|
||||||
_board3D.ConfigureRuntime(_animationConfig, _inputLock);
|
_board3D.ConfigureRuntime(_animationConfig, _inputLock);
|
||||||
|
_triggerSequence = new TriggerCheckSequence(
|
||||||
|
_board3D.CameraDirector,
|
||||||
|
_inputLock);
|
||||||
_board3D.ArtProvider = ResolveBoardArt;
|
_board3D.ArtProvider = ResolveBoardArt;
|
||||||
_board3D.CardClicked += OnBoard3DCardClicked;
|
_board3D.CardClicked += OnBoard3DCardClicked;
|
||||||
_board3D.CardRightClicked += OnBoard3DCardRightClicked;
|
_board3D.CardRightClicked += OnBoard3DCardRightClicked;
|
||||||
|
|
@ -274,6 +279,9 @@ namespace Vanguard.UI
|
||||||
CancelReconnect();
|
CancelReconnect();
|
||||||
_view = null;
|
_view = null;
|
||||||
_finished = false;
|
_finished = false;
|
||||||
|
_triggerWasPending = false;
|
||||||
|
_lastTriggerKey = null;
|
||||||
|
_triggerSequence?.Cancel();
|
||||||
_confirmConcede = false;
|
_confirmConcede = false;
|
||||||
_concedeLabel.text = "Сдаться";
|
_concedeLabel.text = "Сдаться";
|
||||||
_concede.interactable = true;
|
_concede.interactable = true;
|
||||||
|
|
@ -290,6 +298,7 @@ namespace Vanguard.UI
|
||||||
_mulliganSelected.Clear();
|
_mulliganSelected.Clear();
|
||||||
_mulliganUsed = false;
|
_mulliganUsed = false;
|
||||||
_abilityDamageIndex = -1;
|
_abilityDamageIndex = -1;
|
||||||
|
_abilityDamageIndexes.Clear();
|
||||||
_pendingBoosterSlot = -1;
|
_pendingBoosterSlot = -1;
|
||||||
_pendingBoostAttackerSlot = -2;
|
_pendingBoostAttackerSlot = -2;
|
||||||
ClearGuardSelection();
|
ClearGuardSelection();
|
||||||
|
|
@ -350,6 +359,9 @@ namespace Vanguard.UI
|
||||||
}
|
}
|
||||||
|
|
||||||
_view = null;
|
_view = null;
|
||||||
|
_triggerWasPending = false;
|
||||||
|
_lastTriggerKey = null;
|
||||||
|
_triggerSequence?.Cancel();
|
||||||
HideActionPrompt();
|
HideActionPrompt();
|
||||||
_root.gameObject.SetActive(false);
|
_root.gameObject.SetActive(false);
|
||||||
_result.gameObject.SetActive(false);
|
_result.gameObject.SetActive(false);
|
||||||
|
|
@ -376,6 +388,7 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
UpdateClock();
|
UpdateClock();
|
||||||
UpdateFeedback();
|
UpdateFeedback();
|
||||||
|
_triggerSequence?.Tick(_animationConfig);
|
||||||
RefreshActionPrompt();
|
RefreshActionPrompt();
|
||||||
SanitizeInputLocks();
|
SanitizeInputLocks();
|
||||||
SyncAbilityPrompt();
|
SyncAbilityPrompt();
|
||||||
|
|
@ -767,10 +780,45 @@ namespace Vanguard.UI
|
||||||
}
|
}
|
||||||
|
|
||||||
Render();
|
Render();
|
||||||
|
SyncTriggerSequence();
|
||||||
SyncAbilityPrompt();
|
SyncAbilityPrompt();
|
||||||
SyncCardChoicePrompt();
|
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)
|
private void OnEvents(ServerEvents events)
|
||||||
{
|
{
|
||||||
if (events?.Events != null)
|
if (events?.Events != null)
|
||||||
|
|
@ -1118,6 +1166,8 @@ namespace Vanguard.UI
|
||||||
}
|
}
|
||||||
|
|
||||||
_abilityDamageIndex = -1;
|
_abilityDamageIndex = -1;
|
||||||
|
|
||||||
|
_abilityDamageIndexes.Clear();
|
||||||
_pendingIntents.Clear();
|
_pendingIntents.Clear();
|
||||||
_highlightSlots.Clear();
|
_highlightSlots.Clear();
|
||||||
_pendingBoosterSlot = -1;
|
_pendingBoosterSlot = -1;
|
||||||
|
|
@ -1691,6 +1741,12 @@ namespace Vanguard.UI
|
||||||
MatchFinished = _view != null && _view.IsFinished,
|
MatchFinished = _view != null && _view.IsFinished,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (IsAbilityDamageTargeting())
|
||||||
|
{
|
||||||
|
foreach (var index in _abilityDamageIndexes)
|
||||||
|
state.AbilityDamageIndexes.Add(index);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var key in _highlightSlots)
|
foreach (var key in _highlightSlots)
|
||||||
state.Highlights.Add(key);
|
state.Highlights.Add(key);
|
||||||
|
|
||||||
|
|
@ -2220,6 +2276,56 @@ namespace Vanguard.UI
|
||||||
|| text.Contains("встречн");
|
|| 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)
|
private void ActivateAbility(ActivateAbilityIntent intent)
|
||||||
{
|
{
|
||||||
if (intent == null || _view == null || _pendingAbilityCost != null)
|
if (intent == null || _view == null || _pendingAbilityCost != null)
|
||||||
|
|
@ -2235,6 +2341,7 @@ namespace Vanguard.UI
|
||||||
_pendingAbilityCost = null;
|
_pendingAbilityCost = null;
|
||||||
_abilityCostDiscardInstanceId = 0;
|
_abilityCostDiscardInstanceId = 0;
|
||||||
_abilityDamageIndex = -1;
|
_abilityDamageIndex = -1;
|
||||||
|
_abilityDamageIndexes.Clear();
|
||||||
StartDiscardSelection(
|
StartDiscardSelection(
|
||||||
"Выберите карту в руке для сброса.",
|
"Выберите карту в руке для сброса.",
|
||||||
variants,
|
variants,
|
||||||
|
|
@ -2257,6 +2364,7 @@ namespace Vanguard.UI
|
||||||
_pendingAbilityCost = intent;
|
_pendingAbilityCost = intent;
|
||||||
_abilityCostDiscardInstanceId = 0;
|
_abilityCostDiscardInstanceId = 0;
|
||||||
_abilityDamageIndex = -1;
|
_abilityDamageIndex = -1;
|
||||||
|
_abilityDamageIndexes.Clear();
|
||||||
_pendingIntents.Clear();
|
_pendingIntents.Clear();
|
||||||
_highlightSlots.Clear();
|
_highlightSlots.Clear();
|
||||||
SetStatus(
|
SetStatus(
|
||||||
|
|
@ -2271,10 +2379,13 @@ namespace Vanguard.UI
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (RequiresCounterBlast(_pendingAbilityCost)
|
if (RequiresCounterBlast(_pendingAbilityCost)
|
||||||
&& _abilityDamageIndex < 0)
|
&& _abilityDamageIndexes.Count
|
||||||
|
!= CounterBlastAmount(_pendingAbilityCost))
|
||||||
{
|
{
|
||||||
SetStatus(
|
SetStatus(
|
||||||
"Выберите карту урона для Counter Blast.",
|
"Выберите карты урона для Counter Blast: "
|
||||||
|
+ _abilityDamageIndexes.Count + " / "
|
||||||
|
+ CounterBlastAmount(_pendingAbilityCost) + ".",
|
||||||
new Color(1f, 0.70f, 0.35f));
|
new Color(1f, 0.70f, 0.35f));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2284,6 +2395,9 @@ namespace Vanguard.UI
|
||||||
InstanceId = _pendingAbilityCost.InstanceId,
|
InstanceId = _pendingAbilityCost.InstanceId,
|
||||||
DiscardPrintingId = _pendingAbilityCost.DiscardPrintingId,
|
DiscardPrintingId = _pendingAbilityCost.DiscardPrintingId,
|
||||||
DamageIndex = _abilityDamageIndex,
|
DamageIndex = _abilityDamageIndex,
|
||||||
|
DamageIndexes = _abilityDamageIndexes
|
||||||
|
.OrderBy(index => index)
|
||||||
|
.ToArray(),
|
||||||
};
|
};
|
||||||
ClearAbilityCostSelection();
|
ClearAbilityCostSelection();
|
||||||
OnIntentClicked(activate);
|
OnIntentClicked(activate);
|
||||||
|
|
@ -2294,6 +2408,7 @@ namespace Vanguard.UI
|
||||||
_pendingAbilityCost = null;
|
_pendingAbilityCost = null;
|
||||||
_abilityCostDiscardInstanceId = 0;
|
_abilityCostDiscardInstanceId = 0;
|
||||||
_abilityDamageIndex = -1;
|
_abilityDamageIndex = -1;
|
||||||
|
_abilityDamageIndexes.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PruneAbilityCostSelection()
|
private void PruneAbilityCostSelection()
|
||||||
|
|
@ -2319,8 +2434,11 @@ namespace Vanguard.UI
|
||||||
if (_pendingAbilityCost == null)
|
if (_pendingAbilityCost == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return !RequiresCounterBlast(_pendingAbilityCost)
|
if (!RequiresCounterBlast(_pendingAbilityCost))
|
||||||
|| _abilityDamageIndex >= 0;
|
return true;
|
||||||
|
|
||||||
|
return _abilityDamageIndexes.Count
|
||||||
|
== CounterBlastAmount(_pendingAbilityCost);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleGuardFromDrop(BoardCardRef source)
|
private void ToggleGuardFromDrop(BoardCardRef source)
|
||||||
|
|
@ -2727,6 +2845,7 @@ namespace Vanguard.UI
|
||||||
_pendingAbilityCost = ability;
|
_pendingAbilityCost = ability;
|
||||||
_abilityCostDiscardInstanceId = discardInstanceId;
|
_abilityCostDiscardInstanceId = discardInstanceId;
|
||||||
_abilityDamageIndex = -1;
|
_abilityDamageIndex = -1;
|
||||||
|
_abilityDamageIndexes.Clear();
|
||||||
Render();
|
Render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -3129,11 +3248,18 @@ namespace Vanguard.UI
|
||||||
else if (_pendingAbilityCost != null)
|
else if (_pendingAbilityCost != null)
|
||||||
{
|
{
|
||||||
title = "ОПЛАТА";
|
title = "ОПЛАТА";
|
||||||
hint = RequiresCounterBlast(_pendingAbilityCost)
|
if (RequiresCounterBlast(_pendingAbilityCost))
|
||||||
? _abilityDamageIndex < 0
|
{
|
||||||
? "Выберите карту урона для Counter Blast."
|
var required = CounterBlastAmount(_pendingAbilityCost);
|
||||||
: "Оплата выбрана. Подтвердите в панели действий."
|
hint = "Выберите карты урона для Counter Blast: "
|
||||||
: "Оплата выбрана. Подтвердите в панели действий.";
|
+ _abilityDamageIndexes.Count + " / " + required + ".";
|
||||||
|
if (_abilityDamageIndexes.Count >= required)
|
||||||
|
hint = "Оплата выбрана. Подтвердите в панели действий.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hint = "Оплата выбрана. Подтвердите в панели действий.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (_pendingBoosterSlot >= 0 && _pendingIntents.Count > 0)
|
else if (_pendingBoosterSlot >= 0 && _pendingIntents.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -3525,9 +3651,13 @@ namespace Vanguard.UI
|
||||||
if (!IsAbilityDamageTargeting())
|
if (!IsAbilityDamageTargeting())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_abilityDamageIndex = displayIndex;
|
if (!_abilityDamageIndexes.Remove(displayIndex))
|
||||||
|
_abilityDamageIndexes.Add(displayIndex);
|
||||||
|
_abilityDamageIndex = _abilityDamageIndexes.Count > 0
|
||||||
|
? _abilityDamageIndexes.Min()
|
||||||
|
: -1;
|
||||||
SetStatus(
|
SetStatus(
|
||||||
"Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".",
|
"Выбрано карт урона: " + _abilityDamageIndexes.Count + " / " + CounterBlastAmount(_pendingAbilityCost) + ".",
|
||||||
new Color(0.45f, 0.95f, 0.62f));
|
new Color(0.45f, 0.95f, 0.62f));
|
||||||
Render();
|
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]
|
[Test]
|
||||||
public void Pool_Reuses_Cards_Between_Snapshots()
|
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() =>
|
private static List<Card3D> ActiveCards() =>
|
||||||
Resources.FindObjectsOfTypeAll<Card3D>()
|
Resources.FindObjectsOfTypeAll<Card3D>()
|
||||||
.Where(card => card.gameObject.activeInHierarchy)
|
.Where(card => card.gameObject.activeInHierarchy)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,25 @@
|
||||||
# 3D UX backlog
|
# 3D UX backlog
|
||||||
|
|
||||||
Анализ требований по UX-долгам 3D-поля. **Это план доработок, не реализация.**
|
Анализ требований по UX-долгам 3D-поля и статус реализации.
|
||||||
Дата анализа: **2026-10-24**.
|
Дата анализа: **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`,
|
Промпт предполагает наличие `CardMoveAnimator`, `CameraDirector`,
|
||||||
`TriggerCheckSequence`, `OnCardMoved`/`OnTargetChanged`, `CostPaymentController`,
|
`TriggerCheckSequence`, `OnCardMoved`/`OnTargetChanged`, `CostPaymentController`,
|
||||||
|
|
@ -221,16 +237,19 @@ deck / Drop (если faceDown) показывают рубашку; flip про
|
||||||
5. **P2**: World-Space Power/Shield, удаление/скрытие `CombatStats` при
|
5. **P2**: World-Space Power/Shield, удаление/скрытие `CombatStats` при
|
||||||
необходимости, polish и профилирование.
|
необходимости, polish и профилирование.
|
||||||
|
|
||||||
## 8. Открытые решения перед кодом
|
## 8. Принятые решения
|
||||||
|
|
||||||
- UniTask или Coroutine (в проекте нет ни DOTween, ни UniTask).
|
- **Асинхронность:** без новых пакетов; `CardMoveAnimator` и
|
||||||
- `DropZoneFaceDown` default: `false` по Classic, но нужен ли рубашкой вверх
|
`TriggerCheckSequence` работают через `Tick`/корутинный эквивалент.
|
||||||
для Drop в текущем UX?
|
- **Drop:** `DropZoneFaceDown = false` по Classic; флаг доступен в
|
||||||
- Dash: world-space shader или сегментированный mesh (рекомендация —
|
`PlaymatLayoutConfig`.
|
||||||
shader, fallback — mesh).
|
- **Dash:** UV-tile fallback (`LineTextureMode.Tile` + прокрутка offset);
|
||||||
- Судьба верхнего `CombatStats`: скрыть/оставить debug/перенести к картам.
|
world-space shader оставлен как будущая замена.
|
||||||
- Протокол multi-target cost: новый `CostPaymentIntent` или расширение
|
- **`CombatStats`:** верхний strip пока оставлен как HUD; world-space
|
||||||
`ActivateAbilityIntent` массивами (влияет на `Vanguard.Shared` и
|
Power/Shield — P2.
|
||||||
обратную совместимость).
|
- **Multi-target cost:** расширен `ActivateAbilityIntent.DamageIndexes`
|
||||||
- Камера: кто владеет `Camera` во время trigger sequence и как это
|
(`[Key(3)]`, обратная совместимость); `CounterBlast` принимает массив.
|
||||||
сочетается с `EnsureCamera()`.
|
`Soul Blast` и остальные типы — следующий шаг.
|
||||||
|
- **Камера:** владелец — `CameraDirector`; `EnsureCamera()` не перезаписывает
|
||||||
|
pose, пока `CameraDirector.IsActive`. `TriggerCheckSequence` снимает
|
||||||
|
`InputLock` перед выбором цели и возвращает камеру после hold.
|
||||||
|
|
|
||||||
|
|
@ -1091,3 +1091,19 @@ Battle; Boot и общие сервисы остаются персистент
|
||||||
кнопок выбора в боковой панели нет.
|
кнопок выбора в боковой панели нет.
|
||||||
- Проверки: сервер **144** правил + **45** серверных, `validate` и
|
- Проверки: сервер **144** правил + **45** серверных, `validate` и
|
||||||
`translations check` OK; клиент EditMode **36/36**.
|
`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 — состояние сессии
|
# Vanguard ReMaster — состояние сессии
|
||||||
|
|
||||||
**Дата:** 2026-10-23
|
**Дата:** 2026-10-24
|
||||||
|
|
||||||
**HEAD:** см. `git log -1`
|
**HEAD:** см. `git log -1`
|
||||||
**Коротко:** сервер готов, клиент — рабочий каркас с 3D-сценами.
|
**Коротко:** сервер готов, клиент — рабочий каркас с 3D-сценами.
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
матчмейкинг, WebSocket-матч, история, реплеи.
|
матчмейкинг, WebSocket-матч, история, реплеи.
|
||||||
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
|
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
|
||||||
in-process реестр соединений.
|
in-process реестр соединений.
|
||||||
- Тесты: **149** правил + **45** серверных, CI зелёный.
|
- Тесты: **151** правил + **45** серверных, CI зелёный.
|
||||||
- Стенд: `http://192.168.31.158:18080`, `/health` → ok.
|
- Стенд: `http://192.168.31.158:18080`, `/health` → ok.
|
||||||
- Диагностика Ride в `MatchGrain`: входящие интенты, результат, события,
|
- Диагностика Ride в `MatchGrain`: входящие интенты, результат, события,
|
||||||
снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`.
|
снапшоты и `rideReady` по руке/рангам; логи через `docker compose logs`.
|
||||||
|
|
@ -159,6 +159,19 @@
|
||||||
обновляются на каждом снимке. Зависшие причины `InputLockService`
|
обновляются на каждом снимке. Зависшие причины `InputLockService`
|
||||||
снимаются, когда reconnect/overlay/ability/card-menu/feedback уже
|
снимаются, когда reconnect/overlay/ability/card-menu/feedback уже
|
||||||
неактивны; `board`-лок больше не накапливается на каждом снапшоте.
|
неактивны; `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) открывают
|
- Опциональные AUTO-способности (Barcgal после рейда Marron) открывают
|
||||||
модальный оверлей «Способность» с «Активировать»/«Пропустить»; в боковой
|
модальный оверлей «Способность» с «Активировать»/«Пропустить»; в боковой
|
||||||
панели этих кнопок нет. После подтверждения призыва сервер предлагает
|
панели этих кнопок нет. После подтверждения призыва сервер предлагает
|
||||||
|
|
@ -204,8 +217,10 @@
|
||||||
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
|
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
|
||||||
- Не готово: 3D-модели карт/стола (пока процедурные примитивы и арты),
|
- Не готово: 3D-модели карт/стола (пока процедурные примитивы и арты),
|
||||||
звук, настройки (язык уже есть), DOTween, drag&drop и live-валидация
|
звук, настройки (язык уже есть), DOTween, drag&drop и live-валидация
|
||||||
декбилдера. Камера боя фиксирована; анимации retire/ghost для
|
декбилдера. Камера боя в покое фиксирована, но `CameraDirector` временно
|
||||||
3D-карт ещё не перенесены.
|
ведёт trigger-последовательность. Не перенесены отдельные retire/ghost
|
||||||
|
анимации, `AttackResolutionQueue`, multi-target Soul Blast, world-space
|
||||||
|
Power/Shield.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
|
@ -255,7 +270,7 @@ Standalone Windows:
|
||||||
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
||||||
```
|
```
|
||||||
|
|
||||||
Клиентские проверки: **38/38** EditMode (сборка всех экранов, импорт арта,
|
Клиентские проверки: **40/40** EditMode (сборка всех экранов, импорт арта,
|
||||||
перевод статических/динамических строк, live-переключение языка, рендер
|
перевод статических/динамических строк, live-переключение языка, рендер
|
||||||
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
|
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
|
||||||
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
|
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
|
||||||
|
|
@ -338,10 +353,10 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
|
||||||
|
|
||||||
## Следующие шаги
|
## Следующие шаги
|
||||||
|
|
||||||
1. UX-план 3D-поля: Phase 2–4 закрыты, overlay-экраны вынесены в prefab
|
1. 3D UX: P0 (движение, ориентации, камера/триггер) и базовый P1 (стрелки,
|
||||||
(`Resources/Overlays`), раскладка проверена на 1920×1080 и масштабируется
|
multi-target Counter Blast) готовы. Осталось: `AttackResolutionQueue`,
|
||||||
CanvasScaler'ом. Осталось при желании отполировать позиции на конкретном
|
multi-target Soul Blast/прочие стоимости, world-space Power/Shield,
|
||||||
мониторе.
|
world-space dash shader; полировка позиций на конкретном мониторе.
|
||||||
2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
|
2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
|
||||||
3. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost.
|
3. 3D-наполнение: модели карт/стола, prefab-экраны сцен, анимации retire/ghost.
|
||||||
4. Звук и настройки, DOTween.
|
4. Звук и настройки, DOTween.
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
| 12 | `SelectTriggerIntent` | `index` |
|
| 12 | `SelectTriggerIntent` | `index` |
|
||||||
| 13 | `ResolveAbilityIntent` | `accept`, `damageIndex?` |
|
| 13 | `ResolveAbilityIntent` | `accept`, `damageIndex?` |
|
||||||
| 14 | `ChooseIntent` | `index` |
|
| 14 | `ChooseIntent` | `index` |
|
||||||
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?` |
|
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?`, `damageIndexes?` |
|
||||||
| 16 | `EndTurnIntent` | — |
|
| 16 | `EndTurnIntent` | — |
|
||||||
| 17 | `ConcedeIntent` | — |
|
| 17 | `ConcedeIntent` | — |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ public sealed record ChooseCommand(int Index) : GameCommand;
|
||||||
public sealed record ActivateAbilityCommand(
|
public sealed record ActivateAbilityCommand(
|
||||||
int InstanceId,
|
int InstanceId,
|
||||||
string? DiscardPrintingId = null,
|
string? DiscardPrintingId = null,
|
||||||
int DamageIndex = -1) : GameCommand;
|
int DamageIndex = -1,
|
||||||
|
int[]? DamageIndexes = null) : GameCommand;
|
||||||
public sealed record EndTurnCommand : GameCommand;
|
public sealed record EndTurnCommand : GameCommand;
|
||||||
|
|
||||||
public sealed record CommandResult(bool Accepted, string? Reason)
|
public sealed record CommandResult(bool Accepted, string? Reason)
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ public sealed class GameEngine
|
||||||
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
|
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
|
||||||
private int _resolvingDepth;
|
private int _resolvingDepth;
|
||||||
private int _counterBlastDisplayIndex = -1;
|
private int _counterBlastDisplayIndex = -1;
|
||||||
|
private int[]? _counterBlastDisplayIndexes;
|
||||||
private readonly bool _enforceFirstTurnAttackRule;
|
private readonly bool _enforceFirstTurnAttackRule;
|
||||||
|
|
||||||
/// <summary>
|
/// <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)
|
if (State.Phase != Phase.Main)
|
||||||
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
|
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
|
||||||
|
|
@ -347,6 +352,17 @@ public sealed class GameEngine
|
||||||
var player = State.ActivePlayer;
|
var player = State.ActivePlayer;
|
||||||
var p = State.Players[player];
|
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 unit = AllUnits(player).FirstOrDefault(u => u.Id == instanceId);
|
||||||
var fromHand = false;
|
var fromHand = false;
|
||||||
var fromDrop = false;
|
var fromDrop = false;
|
||||||
|
|
@ -439,6 +455,7 @@ public sealed class GameEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
_counterBlastDisplayIndex = damageIndex;
|
_counterBlastDisplayIndex = damageIndex;
|
||||||
|
_counterBlastDisplayIndexes = damageIndexes;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ability.Resolve(context);
|
ability.Resolve(context);
|
||||||
|
|
@ -446,6 +463,7 @@ public sealed class GameEngine
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_counterBlastDisplayIndex = -1;
|
_counterBlastDisplayIndex = -1;
|
||||||
|
_counterBlastDisplayIndexes = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
|
_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)
|
if (_counterBlastDisplayIndex < order.Count)
|
||||||
{
|
{
|
||||||
var chosen = order[_counterBlastDisplayIndex].Card;
|
var chosen = order[_counterBlastDisplayIndex].Card;
|
||||||
|
|
@ -750,6 +787,8 @@ public sealed class GameEngine
|
||||||
_counterBlastDisplayIndex = -1;
|
_counterBlastDisplayIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_counterBlastDisplayIndexes = null;
|
||||||
|
|
||||||
foreach (var card in damage)
|
foreach (var card in damage)
|
||||||
{
|
{
|
||||||
if (flipped >= amount)
|
if (flipped >= amount)
|
||||||
|
|
@ -758,6 +797,15 @@ public sealed class GameEngine
|
||||||
if (!card.FaceUp)
|
if (!card.FaceUp)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
if (selected is { Length: > 0 }
|
||||||
|
&& selected.Any(index =>
|
||||||
|
index >= 0
|
||||||
|
&& index < order.Count
|
||||||
|
&& ReferenceEquals(order[index].Card, card)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
card.FaceUp = false;
|
card.FaceUp = false;
|
||||||
flipped++;
|
flipped++;
|
||||||
}
|
}
|
||||||
|
|
@ -1783,7 +1831,11 @@ public sealed class GameEngine
|
||||||
SelectTriggerCommand c => SelectTrigger(c.Index),
|
SelectTriggerCommand c => SelectTrigger(c.Index),
|
||||||
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex),
|
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex),
|
||||||
ChooseCommand c => Choose(c.Index),
|
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(),
|
EndTurnCommand => EndTurn(),
|
||||||
_ => CommandResult.Reject("Неизвестная команда."),
|
_ => CommandResult.Reject("Неизвестная команда."),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public static class CommandCodec
|
||||||
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
|
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
|
||||||
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}{Separator}{c.DamageIndex}",
|
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}{Separator}{c.DamageIndex}",
|
||||||
ChooseCommand c => $"choose{Separator}{c.Index}",
|
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",
|
EndTurnCommand => "endTurn",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
||||||
};
|
};
|
||||||
|
|
@ -63,7 +63,10 @@ public static class CommandCodec
|
||||||
"activate" => new ActivateAbilityCommand(
|
"activate" => new ActivateAbilityCommand(
|
||||||
ParseInt(parts[1]),
|
ParseInt(parts[1]),
|
||||||
Nullable(parts[2]),
|
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(),
|
"endTurn" => new EndTurnCommand(),
|
||||||
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
|
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ public static class IntentMapping
|
||||||
InstanceId = c.InstanceId,
|
InstanceId = c.InstanceId,
|
||||||
DiscardPrintingId = c.DiscardPrintingId,
|
DiscardPrintingId = c.DiscardPrintingId,
|
||||||
DamageIndex = c.DamageIndex,
|
DamageIndex = c.DamageIndex,
|
||||||
|
DamageIndexes = c.DamageIndexes ?? System.Array.Empty<int>(),
|
||||||
},
|
},
|
||||||
EndTurnCommand => new EndTurnIntent(),
|
EndTurnCommand => new EndTurnIntent(),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
||||||
|
|
@ -73,7 +74,11 @@ public static class IntentMapping
|
||||||
SelectTriggerIntent c => new SelectTriggerCommand(c.Index),
|
SelectTriggerIntent c => new SelectTriggerCommand(c.Index),
|
||||||
ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept, c.DamageIndex),
|
ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept, c.DamageIndex),
|
||||||
ChooseIntent c => new ChooseCommand(c.Index),
|
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(),
|
EndTurnIntent => new EndTurnCommand(),
|
||||||
ConcedeIntent => null,
|
ConcedeIntent => null,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."),
|
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."),
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,12 @@ public sealed class ActivateAbilityIntent : IntentPayload
|
||||||
|
|
||||||
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
|
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
|
||||||
[Key(2)] public int DamageIndex { get; set; } = -1;
|
[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]
|
[MessagePackObject]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Vanguard.Rules.Cards;
|
using Vanguard.Rules.Cards;
|
||||||
using Vanguard.Rules.Decks;
|
using Vanguard.Rules.Decks;
|
||||||
using Vanguard.Rules.Engine;
|
using Vanguard.Rules.Engine;
|
||||||
|
|
@ -231,6 +231,46 @@ public class GameEngineTests
|
||||||
Assert.False(second.FaceUp);
|
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]
|
[Fact]
|
||||||
public void Called_RearGuard_Enters_Standing_And_Replaces_Occupied_Circle()
|
public void Called_RearGuard_Enters_Standing_And_Replaces_Occupied_Circle()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue