Add input lock, card actions, combat stats and attack arrow
CI / test (push) Successful in 2m27s
Details
CI / test (push) Successful in 2m27s
Details
This commit is contained in:
parent
309b56e4d7
commit
50487ed3f0
|
|
@ -5,6 +5,7 @@ using UnityEditor.SceneManagement;
|
|||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using Vanguard.App;
|
||||
using Vanguard.Battle;
|
||||
using Vanguard.Board3D;
|
||||
|
||||
namespace Vanguard.Editor
|
||||
|
|
@ -20,6 +21,8 @@ namespace Vanguard.Editor
|
|||
private const string BootPath = "Assets/Scenes/Boot.unity";
|
||||
private const string PlaymatConfigPath =
|
||||
"Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset";
|
||||
private const string AnimationConfigPath =
|
||||
"Assets/Vanguard/Resources/AnimationConfig.asset";
|
||||
private const string PlaymatScenePath = SceneDir + "/GameBoard3D.unity";
|
||||
|
||||
[MenuItem("Vanguard/Rebuild Client Scenes")]
|
||||
|
|
@ -36,6 +39,7 @@ namespace Vanguard.Editor
|
|||
Create<DeckBuilderSceneController>("DeckBuilder");
|
||||
Create<BattleSceneController>("Battle");
|
||||
EnsurePlaymatConfig();
|
||||
EnsureAnimationConfig();
|
||||
|
||||
var scenes = new List<EditorBuildSettingsScene>();
|
||||
Add(scenes, BootPath);
|
||||
|
|
@ -92,6 +96,35 @@ namespace Vanguard.Editor
|
|||
Debug.Log("Vanguard playmat sandbox rebuilt: " + PlaymatScenePath);
|
||||
}
|
||||
|
||||
[MenuItem("Vanguard/Rebuild Animation Config")]
|
||||
public static void BuildAnimationConfig()
|
||||
{
|
||||
EnsureAnimationConfig();
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log("Vanguard animation config ready: " + AnimationConfigPath);
|
||||
}
|
||||
|
||||
private static AnimationConfig EnsureAnimationConfig()
|
||||
{
|
||||
var config = AssetDatabase.LoadAssetAtPath<AnimationConfig>(AnimationConfigPath);
|
||||
if (config != null)
|
||||
return config;
|
||||
|
||||
var directory = Path.GetDirectoryName(AnimationConfigPath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
|
||||
config = ScriptableObject.CreateInstance<AnimationConfig>();
|
||||
config.name = "AnimationConfig";
|
||||
AssetDatabase.CreateAsset(config, AnimationConfigPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
return config;
|
||||
}
|
||||
|
||||
private static PlaymatLayoutConfig EnsurePlaymatConfig()
|
||||
{
|
||||
var config = AssetDatabase.LoadAssetAtPath<PlaymatLayoutConfig>(PlaymatConfigPath);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 0636c0f7bf02c0548adae5e43ddb8f26, type: 3}
|
||||
m_Name: AnimationConfig
|
||||
m_EditorClassIdentifier: Vanguard.Runtime::Vanguard.Battle.AnimationConfig
|
||||
cardMoveSpeed: 14
|
||||
cardRotateSpeed: 12
|
||||
cardMoveDuration: 0.45
|
||||
cardFlipDuration: 0.3
|
||||
callMoveDuration: 0.55
|
||||
retireDuration: 0.8
|
||||
attackDeclare: 1.2
|
||||
guard: 1.9
|
||||
driveCheck: 1.5
|
||||
trigger: 1.4
|
||||
battleResolved: 1.3
|
||||
damage: 1.6
|
||||
heal: 1.4
|
||||
intercept: 1.9
|
||||
perfectGuard: 2
|
||||
inputLockTail: 0.15
|
||||
arrowShowDuration: 0.25
|
||||
arrowHideDuration: 0.2
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 233e60b202fccbf4d868f32ee2a54777
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace Vanguard.Battle
|
||||
{
|
||||
/// <summary>
|
||||
/// Длительности боевых анимаций и блокировки ввода. Ассет лежит в
|
||||
/// Resources/AnimationConfig; если его нет, используются значения по умолчанию.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Vanguard/Animation Config", fileName = "AnimationConfig")]
|
||||
public sealed class AnimationConfig : ScriptableObject
|
||||
{
|
||||
[Header("Карты")]
|
||||
public float cardMoveSpeed = 14f;
|
||||
public float cardRotateSpeed = 12f;
|
||||
public float cardMoveDuration = 0.45f;
|
||||
public float cardFlipDuration = 0.3f;
|
||||
public float callMoveDuration = 0.55f;
|
||||
public float retireDuration = 0.8f;
|
||||
|
||||
[Header("Бой")]
|
||||
public float attackDeclare = 1.2f;
|
||||
public float guard = 1.9f;
|
||||
public float driveCheck = 1.5f;
|
||||
public float trigger = 1.4f;
|
||||
public float battleResolved = 1.3f;
|
||||
public float damage = 1.6f;
|
||||
public float heal = 1.4f;
|
||||
public float intercept = 1.9f;
|
||||
public float perfectGuard = 2.0f;
|
||||
|
||||
[Header("Ввод")]
|
||||
public float inputLockTail = 0.15f;
|
||||
|
||||
[Header("Стрелка атаки")]
|
||||
public float arrowShowDuration = 0.25f;
|
||||
public float arrowHideDuration = 0.2f;
|
||||
|
||||
private static AnimationConfig _default;
|
||||
|
||||
public static AnimationConfig Default
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_default == null)
|
||||
{
|
||||
_default = CreateInstance<AnimationConfig>();
|
||||
_default.name = "AnimationConfig (runtime)";
|
||||
_default.hideFlags = HideFlags.DontSave;
|
||||
}
|
||||
|
||||
return _default;
|
||||
}
|
||||
}
|
||||
|
||||
public static AnimationConfig LoadOrDefault()
|
||||
{
|
||||
var loaded = Resources.Load<AnimationConfig>("AnimationConfig");
|
||||
return loaded != null ? loaded : Default;
|
||||
}
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
cardMoveSpeed = Mathf.Max(1f, cardMoveSpeed);
|
||||
cardRotateSpeed = Mathf.Max(1f, cardRotateSpeed);
|
||||
cardMoveDuration = Mathf.Max(0.05f, cardMoveDuration);
|
||||
cardFlipDuration = Mathf.Max(0.05f, cardFlipDuration);
|
||||
callMoveDuration = Mathf.Max(0.05f, callMoveDuration);
|
||||
retireDuration = Mathf.Max(0.05f, retireDuration);
|
||||
attackDeclare = Mathf.Max(0.05f, attackDeclare);
|
||||
guard = Mathf.Max(0.05f, guard);
|
||||
driveCheck = Mathf.Max(0.05f, driveCheck);
|
||||
trigger = Mathf.Max(0.05f, trigger);
|
||||
battleResolved = Mathf.Max(0.05f, battleResolved);
|
||||
damage = Mathf.Max(0.05f, damage);
|
||||
heal = Mathf.Max(0.05f, heal);
|
||||
intercept = Mathf.Max(0.05f, intercept);
|
||||
perfectGuard = Mathf.Max(0.05f, perfectGuard);
|
||||
inputLockTail = Mathf.Max(0f, inputLockTail);
|
||||
arrowShowDuration = Mathf.Max(0.05f, arrowShowDuration);
|
||||
arrowHideDuration = Mathf.Max(0.05f, arrowHideDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0636c0f7bf02c0548adae5e43ddb8f26
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Vanguard.Battle
|
||||
{
|
||||
/// <summary>
|
||||
/// Счётчик причин блокировки: анимация, оверлей, контекстное меню.
|
||||
/// Ввод возвращается только когда снята последняя причина.
|
||||
/// </summary>
|
||||
public sealed class InputLockService
|
||||
{
|
||||
private readonly Dictionary<string, int> _reasons =
|
||||
new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
public bool IsLocked => _reasons.Count > 0;
|
||||
|
||||
public event Action<bool> Changed;
|
||||
|
||||
public void Acquire(string reason)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
return;
|
||||
|
||||
var wasLocked = _reasons.Count > 0;
|
||||
_reasons.TryGetValue(reason, out var count);
|
||||
_reasons[reason] = count + 1;
|
||||
|
||||
if (!wasLocked)
|
||||
Changed?.Invoke(true);
|
||||
}
|
||||
|
||||
public void Release(string reason)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
return;
|
||||
|
||||
if (!_reasons.TryGetValue(reason, out var count))
|
||||
return;
|
||||
|
||||
if (count > 1)
|
||||
{
|
||||
_reasons[reason] = count - 1;
|
||||
return;
|
||||
}
|
||||
|
||||
_reasons.Remove(reason);
|
||||
if (_reasons.Count == 0)
|
||||
Changed?.Invoke(false);
|
||||
}
|
||||
|
||||
public void ReleaseAll()
|
||||
{
|
||||
if (_reasons.Count == 0)
|
||||
return;
|
||||
|
||||
_reasons.Clear();
|
||||
Changed?.Invoke(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 74c6d538edc00d040ac6486bfa5513ce
|
||||
|
|
@ -51,10 +51,11 @@ MonoBehaviour:
|
|||
opponentHandScale: 0.8
|
||||
opponentHandRoll: 2.5
|
||||
guardianRowGap: 0.55
|
||||
guardianSpacing: 0.44
|
||||
guardianSpacing: 0.62
|
||||
guardianMaxWidth: 3.6
|
||||
damageX: 3
|
||||
pileX: 2.95
|
||||
showZoneLabels: 0
|
||||
cameraPosition: {x: 1.4, y: 5.8, z: -8}
|
||||
cameraTarget: {x: 1.4, y: 0, z: 0}
|
||||
cameraFov: 46
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ namespace Vanguard.Board3D
|
|||
|
||||
[Header("Круг защиты")]
|
||||
public float guardianRowGap = 0.55f;
|
||||
public float guardianSpacing = 0.44f;
|
||||
public float guardianSpacing = 0.62f;
|
||||
public float guardianMaxWidth = 3.6f;
|
||||
|
||||
[Header("Стопки")]
|
||||
|
|
@ -81,6 +81,10 @@ namespace Vanguard.Board3D
|
|||
[Tooltip("Расстояние от центра стола до колоды и дропа.")]
|
||||
public float pileX = 2.95f;
|
||||
|
||||
[Header("Отладка")]
|
||||
[Tooltip("Показывать подписи зон на 3D-столе.")]
|
||||
public bool showZoneLabels = false;
|
||||
|
||||
[Header("Камера")]
|
||||
[Tooltip("Фиксированная камера над полем. Не двигается игроком.")]
|
||||
public Vector3 cameraPosition = new Vector3(1.4f, 5.8f, -8.0f);
|
||||
|
|
|
|||
|
|
@ -92,9 +92,10 @@ namespace Vanguard.Board3D
|
|||
{
|
||||
var x = (localSeat == 0 ? 1f : -1f) * _config.pileX;
|
||||
var offset = index - (Mathf.Max(1, count) - 1) * 0.5f;
|
||||
var sideSign = localSeat == 0 ? 1f : -1f;
|
||||
// «Над колодой» — дальше от камеры, то есть выше на экране.
|
||||
var z = (ZSign(localSeat) * _config.frontRowZ) + 1.0f;
|
||||
return new Vector3(x + offset * 0.42f, _config.cardLift, z);
|
||||
return new Vector3(x + offset * 0.42f * sideSign, _config.cardLift, z);
|
||||
}
|
||||
|
||||
/// <summary>Душа — визуальная стопка под кругом V, чуть ближе к игроку.</summary>
|
||||
|
|
@ -113,7 +114,11 @@ namespace Vanguard.Board3D
|
|||
|
||||
var offset = index - (count - 1) * 0.5f;
|
||||
var z = ZSign(localSeat) * _config.guardianRowGap;
|
||||
return new Vector3(offset * spacing, _config.GuardianCenterY, z);
|
||||
var stackLift = index * 0.0015f;
|
||||
return new Vector3(
|
||||
offset * spacing,
|
||||
_config.GuardianCenterY + stackLift,
|
||||
z);
|
||||
}
|
||||
|
||||
/// <summary>Веер руки перед локальным игроком.</summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
using UnityEngine;
|
||||
using Vanguard.Battle;
|
||||
|
||||
namespace Vanguard.Board3D
|
||||
{
|
||||
/// <summary>
|
||||
/// Стрелка атаки на столе: линия от атакующего к цели и наконечник.
|
||||
/// Точки обновляются каждый кадр, поэтому стрелка едет за картами.
|
||||
/// </summary>
|
||||
public sealed class AttackArrowView
|
||||
{
|
||||
private const float ShaftWidth = 0.075f;
|
||||
private const float HeadWidth = 0.17f;
|
||||
private const float HeadLength = 0.38f;
|
||||
|
||||
private GameObject _root;
|
||||
private LineRenderer _shaft;
|
||||
private LineRenderer _head;
|
||||
private Material _material;
|
||||
private AnimationConfig _config;
|
||||
private Vector3 _from;
|
||||
private Vector3 _to;
|
||||
private bool _visible;
|
||||
private float _alpha;
|
||||
private float _widthScale = 1f;
|
||||
|
||||
public bool IsVisible => _visible || _alpha > 0.001f;
|
||||
|
||||
public void Build(Transform parent, AnimationConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_root = new GameObject("Attack Arrow");
|
||||
_root.transform.SetParent(parent, false);
|
||||
|
||||
var shader = Shader.Find("Sprites/Default");
|
||||
if (shader == null)
|
||||
shader = Shader.Find("Unlit/Color");
|
||||
|
||||
_material = shader != null ? new Material(shader) : null;
|
||||
_shaft = CreateLine("Shaft", ShaftWidth);
|
||||
_head = CreateLine("Head", ShaftWidth * 0.7f);
|
||||
_head.positionCount = 3;
|
||||
_root.SetActive(false);
|
||||
}
|
||||
|
||||
public void SetConfig(AnimationConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public void Show(Vector3 from, Vector3 to)
|
||||
{
|
||||
SetPoints(from, to);
|
||||
_visible = true;
|
||||
|
||||
if (_root != null)
|
||||
_root.SetActive(true);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
_visible = false;
|
||||
}
|
||||
|
||||
public void HideImmediate()
|
||||
{
|
||||
_visible = false;
|
||||
_alpha = 0f;
|
||||
if (_root != null)
|
||||
_root.SetActive(false);
|
||||
}
|
||||
|
||||
public void SetPoints(Vector3 from, Vector3 to)
|
||||
{
|
||||
_from = from;
|
||||
_to = to;
|
||||
UpdateGeometry();
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime)
|
||||
{
|
||||
if (_root == null)
|
||||
return;
|
||||
|
||||
var target = _visible ? 1f : 0f;
|
||||
var duration = _visible
|
||||
? (_config != null ? _config.arrowShowDuration : 0.25f)
|
||||
: (_config != null ? _config.arrowHideDuration : 0.2f);
|
||||
_alpha = Mathf.MoveTowards(
|
||||
_alpha,
|
||||
target,
|
||||
deltaTime / Mathf.Max(0.01f, duration));
|
||||
|
||||
if (!_visible && _alpha <= 0.001f)
|
||||
{
|
||||
_root.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_root.SetActive(true);
|
||||
_widthScale = 0.55f + (_alpha * 0.45f);
|
||||
UpdateGeometry();
|
||||
ApplyColor();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_root != null)
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
Object.Destroy(_root);
|
||||
else
|
||||
Object.DestroyImmediate(_root);
|
||||
_root = null;
|
||||
}
|
||||
|
||||
if (_material != null)
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
Object.Destroy(_material);
|
||||
else
|
||||
Object.DestroyImmediate(_material);
|
||||
_material = null;
|
||||
}
|
||||
}
|
||||
|
||||
private LineRenderer CreateLine(string name, float width)
|
||||
{
|
||||
var go = new GameObject(name);
|
||||
go.transform.SetParent(_root.transform, false);
|
||||
var line = go.AddComponent<LineRenderer>();
|
||||
line.useWorldSpace = false;
|
||||
line.positionCount = 2;
|
||||
line.widthMultiplier = width;
|
||||
line.numCapVertices = 4;
|
||||
line.numCornerVertices = 4;
|
||||
line.alignment = LineAlignment.View;
|
||||
line.textureMode = LineTextureMode.Stretch;
|
||||
line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
line.receiveShadows = false;
|
||||
if (_material != null)
|
||||
line.material = _material;
|
||||
return line;
|
||||
}
|
||||
|
||||
private void UpdateGeometry()
|
||||
{
|
||||
if (_shaft == null || _head == null)
|
||||
return;
|
||||
|
||||
var direction = _to - _from;
|
||||
if (direction.sqrMagnitude < 0.0001f)
|
||||
direction = Vector3.forward;
|
||||
direction.Normalize();
|
||||
|
||||
_shaft.SetPosition(0, _from);
|
||||
_shaft.SetPosition(1, _to);
|
||||
_shaft.widthMultiplier = ShaftWidth * _widthScale;
|
||||
|
||||
var tip = _to;
|
||||
var back = _to - (direction * HeadLength);
|
||||
var side = Vector3.Cross(Vector3.up, direction).normalized;
|
||||
if (side.sqrMagnitude < 0.0001f)
|
||||
side = Vector3.right;
|
||||
var half = side * (HeadWidth * _widthScale);
|
||||
_head.SetPosition(0, back + half);
|
||||
_head.SetPosition(1, tip);
|
||||
_head.SetPosition(2, back - half);
|
||||
_head.widthMultiplier = ShaftWidth * 0.7f * _widthScale;
|
||||
}
|
||||
|
||||
private void ApplyColor()
|
||||
{
|
||||
var color = new Color(1f, 0.72f, 0.26f, _alpha * 0.92f);
|
||||
foreach (var line in new[] { _shaft, _head })
|
||||
{
|
||||
if (line == null)
|
||||
continue;
|
||||
|
||||
line.startColor = color;
|
||||
line.endColor = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: f6b37f3c1f69dcb419ed41db1abe16fc
|
||||
|
|
@ -29,6 +29,8 @@ namespace Vanguard.Board3D
|
|||
private bool _dragging;
|
||||
private Vector3 _dragOriginPosition;
|
||||
private Quaternion _dragOriginRotation;
|
||||
private float _moveSpeed = 16f;
|
||||
private float _rotateSpeed = 14f;
|
||||
|
||||
public BoardCardRef Ref { get; private set; }
|
||||
public bool IsFaceUp => _faceUp;
|
||||
|
|
@ -37,6 +39,11 @@ namespace Vanguard.Board3D
|
|||
public Vector3 TargetPosition => _targetPosition;
|
||||
public float TargetYaw => _targetRotation.eulerAngles.y;
|
||||
public Quaternion TargetRotation => _targetRotation;
|
||||
public bool IsAnimating =>
|
||||
!_dragging
|
||||
&& (Vector3.SqrMagnitude(transform.position - _targetPosition) > 0.0000004f
|
||||
|| Quaternion.Angle(transform.rotation, _targetRotation) > 0.1f
|
||||
|| Vector3.SqrMagnitude(transform.localScale - _targetScale) > 0.0000004f);
|
||||
public string StatsText => _stats != null && _stats.gameObject.activeSelf
|
||||
? _stats.text
|
||||
: null;
|
||||
|
|
@ -53,6 +60,8 @@ namespace Vanguard.Board3D
|
|||
private void Build(PlaymatLayoutConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_moveSpeed = config.smoothMoveSpeed;
|
||||
_rotateSpeed = config.smoothRotateSpeed;
|
||||
_block = new MaterialPropertyBlock();
|
||||
|
||||
var width = _config.cardWidth;
|
||||
|
|
@ -138,8 +147,13 @@ namespace Vanguard.Board3D
|
|||
_front.SetPropertyBlock(_block);
|
||||
}
|
||||
|
||||
public void SetStats(string text, bool modified)
|
||||
public void SetAnimationSpeeds(float moveSpeed, float rotateSpeed)
|
||||
{
|
||||
_moveSpeed = Mathf.Max(1f, moveSpeed);
|
||||
_rotateSpeed = Mathf.Max(1f, rotateSpeed);
|
||||
}
|
||||
|
||||
public void SetStats(string text, bool modified) {
|
||||
if (_stats == null)
|
||||
return;
|
||||
|
||||
|
|
@ -297,8 +311,8 @@ namespace Vanguard.Board3D
|
|||
return;
|
||||
}
|
||||
|
||||
var move = 1f - Mathf.Exp(-_config.smoothMoveSpeed * deltaTime);
|
||||
var rotate = 1f - Mathf.Exp(-_config.smoothRotateSpeed * deltaTime);
|
||||
var move = 1f - Mathf.Exp(-_moveSpeed * deltaTime);
|
||||
var rotate = 1f - Mathf.Exp(-_rotateSpeed * deltaTime);
|
||||
transform.position = Vector3.Lerp(transform.position, _targetPosition, move);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, _targetRotation, rotate);
|
||||
transform.localScale = Vector3.Lerp(transform.localScale, _targetScale, move);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem;
|
||||
using Vanguard.Battle;
|
||||
using Vanguard.Shared.Protocol;
|
||||
|
||||
namespace Vanguard.Board3D
|
||||
|
|
@ -44,9 +45,13 @@ namespace Vanguard.Board3D
|
|||
private Transform _pilesRoot;
|
||||
private Transform _zonesRoot;
|
||||
private Camera _camera;
|
||||
private InputLockService _inputLock;
|
||||
private AnimationConfig _animationConfig;
|
||||
private float _boardLockReleaseAt = -1f;
|
||||
|
||||
private BoardZoneView _guardianZone;
|
||||
private BoardZoneView _dropIndicator;
|
||||
private AttackArrowView _attackArrow;
|
||||
private Pile3D _deckSelf;
|
||||
private Pile3D _deckOpponent;
|
||||
private Pile3D _dropSelf;
|
||||
|
|
@ -72,8 +77,53 @@ namespace Vanguard.Board3D
|
|||
public PlaymatLayoutConfig Config => _config;
|
||||
public PlaymatLayout Layout => _layout;
|
||||
public int SelfPlayer => _selfPlayer;
|
||||
public bool AttackArrowVisible =>
|
||||
_attackArrow != null && _attackArrow.IsVisible;
|
||||
public BoardArtProvider ArtProvider { get; set; }
|
||||
|
||||
public bool TryGetCardScreenPosition(BoardCardRef reference, out Vector2 screen)
|
||||
{
|
||||
screen = default;
|
||||
if (_camera == null)
|
||||
_camera = Camera.main;
|
||||
if (_camera == null)
|
||||
return false;
|
||||
|
||||
foreach (var card in _cards.Values)
|
||||
{
|
||||
if (card == null || !card.Ref.Equals(reference))
|
||||
continue;
|
||||
|
||||
var point = _camera.WorldToScreenPoint(card.transform.position);
|
||||
if (point.z <= 0f)
|
||||
return false;
|
||||
|
||||
screen = new Vector2(point.x, point.y);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ConfigureRuntime(
|
||||
AnimationConfig animationConfig,
|
||||
InputLockService inputLock)
|
||||
{
|
||||
_animationConfig = animationConfig;
|
||||
_inputLock = inputLock;
|
||||
_attackArrow?.SetConfig(_animationConfig);
|
||||
|
||||
if (_animationConfig == null)
|
||||
return;
|
||||
|
||||
foreach (var card in _cards.Values)
|
||||
{
|
||||
card.SetAnimationSpeeds(
|
||||
_animationConfig.cardMoveSpeed,
|
||||
_animationConfig.cardRotateSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<BoardCardRef> CardClicked;
|
||||
public event Action<BoardCardRef> CardRightClicked;
|
||||
public event Action<BoardCardRef> SlotClicked;
|
||||
|
|
@ -117,6 +167,7 @@ namespace Vanguard.Board3D
|
|||
BuildZones();
|
||||
BuildPiles();
|
||||
BuildDropIndicator();
|
||||
BuildAttackArrow();
|
||||
|
||||
_cardsRoot = new GameObject("Cards").transform;
|
||||
_cardsRoot.SetParent(transform, false);
|
||||
|
|
@ -149,6 +200,8 @@ namespace Vanguard.Board3D
|
|||
CancelPointer();
|
||||
_hasRendered = false;
|
||||
_handBindings.Clear();
|
||||
_attackArrow?.HideImmediate();
|
||||
ReleaseBoardLock();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -232,15 +285,115 @@ namespace Vanguard.Board3D
|
|||
|
||||
UpdatePiles(view);
|
||||
UpdateSlots(state);
|
||||
UpdateAttackArrowState(view);
|
||||
_hasRendered = true;
|
||||
}
|
||||
|
||||
if (_inputLock != null
|
||||
&& _animationConfig != null
|
||||
&& AnyCardAnimating())
|
||||
{
|
||||
_inputLock.Acquire("board");
|
||||
_boardLockReleaseAt = Time.unscaledTime
|
||||
+ Mathf.Max(1f, _animationConfig.cardMoveDuration * 3f)
|
||||
+ _animationConfig.inputLockTail;
|
||||
}
|
||||
}
|
||||
public void Clear()
|
||||
{
|
||||
ClearCards();
|
||||
_handBindings.Clear();
|
||||
_view = null;
|
||||
_hasRendered = false;
|
||||
_attackArrow?.HideImmediate();
|
||||
ReleaseBoardLock();
|
||||
}
|
||||
|
||||
private void UpdateAttackArrowState(MatchView view)
|
||||
{
|
||||
if (_attackArrow == null)
|
||||
return;
|
||||
|
||||
if (view?.Battle == null
|
||||
|| !TryResolveAttackPoints(view, out var from, out var to))
|
||||
{
|
||||
_attackArrow.Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
_attackArrow.Show(from, to);
|
||||
}
|
||||
|
||||
private void UpdateAttackArrowPoints()
|
||||
{
|
||||
if (_attackArrow == null || !_attackArrow.IsVisible || _view?.Battle == null)
|
||||
return;
|
||||
|
||||
if (TryResolveAttackPoints(_view, out var from, out var to))
|
||||
_attackArrow.SetPoints(from, to);
|
||||
}
|
||||
|
||||
private bool TryResolveAttackPoints(
|
||||
MatchView view,
|
||||
out Vector3 from,
|
||||
out Vector3 to)
|
||||
{
|
||||
from = default;
|
||||
to = default;
|
||||
var battle = view?.Battle;
|
||||
if (battle == null)
|
||||
return false;
|
||||
|
||||
var attacker = BattleCard(view, battle.AttackerPlayer, battle.AttackerSlot);
|
||||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||||
var defender = BattleCard(view, defenderIndex, battle.TargetSlot);
|
||||
if (attacker == null || defender == null)
|
||||
return false;
|
||||
|
||||
from = ArrowAnchor(
|
||||
BoardCardRef.Field(battle.AttackerPlayer, battle.AttackerSlot, attacker),
|
||||
battle.AttackerPlayer,
|
||||
battle.AttackerSlot);
|
||||
to = ArrowAnchor(
|
||||
BoardCardRef.Field(defenderIndex, battle.TargetSlot, defender),
|
||||
defenderIndex,
|
||||
battle.TargetSlot);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Vector3 ArrowAnchor(BoardCardRef reference, int player, int slot)
|
||||
{
|
||||
var card = FindCard(reference);
|
||||
if (card != null)
|
||||
return card.transform.localPosition + (Vector3.up * 0.2f);
|
||||
|
||||
return _layout.CirclePositionForPlayer(player, _selfPlayer, slot)
|
||||
+ (Vector3.up * 0.2f);
|
||||
}
|
||||
|
||||
private CardView BattleCard(MatchView view, int playerIndex, int slot)
|
||||
{
|
||||
var player = view?.Players.FirstOrDefault(item => item.Index == playerIndex);
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
if (slot < 0)
|
||||
return player.Vanguard;
|
||||
|
||||
return player.RearGuards != null
|
||||
&& slot < player.RearGuards.Length
|
||||
? player.RearGuards[slot]
|
||||
: null;
|
||||
}
|
||||
|
||||
private Card3D FindCard(BoardCardRef reference)
|
||||
{
|
||||
foreach (var card in _cards.Values)
|
||||
{
|
||||
if (card != null && card.Ref.Equals(reference))
|
||||
return card;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void BuildTable()
|
||||
|
|
@ -376,7 +529,7 @@ namespace Vanguard.Board3D
|
|||
1.05f,
|
||||
Mathf.Max(0.016f, _config.cardWidth * 0.025f),
|
||||
new Color(0.30f, 0.18f, 0.52f),
|
||||
"TRIGGER",
|
||||
_config.showZoneLabels ? "TRIGGER" : string.Empty,
|
||||
0.04f,
|
||||
zSign * 0.72f,
|
||||
new Color(0.82f, 0.76f, 1f, 0.75f),
|
||||
|
|
@ -389,10 +542,22 @@ namespace Vanguard.Board3D
|
|||
_pilesRoot = new GameObject("Piles").transform;
|
||||
_pilesRoot.SetParent(transform, false);
|
||||
|
||||
_deckSelf = CreatePile(BoardZone.Deck, 0, 0, "DECK", new Color(0.20f, 0.34f, 0.60f));
|
||||
_deckOpponent = CreatePile(BoardZone.Deck, 1, 1, "DECK", new Color(0.60f, 0.26f, 0.26f));
|
||||
_dropSelf = CreatePile(BoardZone.Drop, 0, 0, "DROP", new Color(0.30f, 0.30f, 0.40f));
|
||||
_dropOpponent = CreatePile(BoardZone.Drop, 1, 1, "DROP", new Color(0.40f, 0.28f, 0.28f));
|
||||
_deckSelf = CreatePile(
|
||||
BoardZone.Deck, 0, 0,
|
||||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||||
new Color(0.20f, 0.34f, 0.60f));
|
||||
_deckOpponent = CreatePile(
|
||||
BoardZone.Deck, 1, 1,
|
||||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||||
new Color(0.60f, 0.26f, 0.26f));
|
||||
_dropSelf = CreatePile(
|
||||
BoardZone.Drop, 0, 0,
|
||||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||||
new Color(0.30f, 0.30f, 0.40f));
|
||||
_dropOpponent = CreatePile(
|
||||
BoardZone.Drop, 1, 1,
|
||||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||||
new Color(0.40f, 0.28f, 0.28f));
|
||||
}
|
||||
|
||||
private Pile3D CreatePile(
|
||||
|
|
@ -439,6 +604,12 @@ namespace Vanguard.Board3D
|
|||
_dropIndicator.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private void BuildAttackArrow()
|
||||
{
|
||||
_attackArrow = new AttackArrowView();
|
||||
_attackArrow.Build(transform, _animationConfig);
|
||||
}
|
||||
|
||||
private void EnsureCamera()
|
||||
{
|
||||
if (_camera == null)
|
||||
|
|
@ -540,7 +711,10 @@ namespace Vanguard.Board3D
|
|||
Ref = BoardCardRef.Guardian(player.Index, i, card),
|
||||
Art = ResolveArt(card.PrintingId),
|
||||
Position = _layout.GuardianPosition(localSeat, i, total),
|
||||
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
|
||||
BaseRotation = Quaternion.Euler(
|
||||
0f,
|
||||
_layout.BaseYaw(localSeat) + 90f,
|
||||
0f),
|
||||
FaceUp = true,
|
||||
Rested = false,
|
||||
});
|
||||
|
|
@ -558,7 +732,10 @@ namespace Vanguard.Board3D
|
|||
localSeat,
|
||||
guardians.Count + i,
|
||||
total),
|
||||
BaseRotation = Quaternion.Euler(0f, _layout.BaseYaw(localSeat), 0f),
|
||||
BaseRotation = Quaternion.Euler(
|
||||
0f,
|
||||
_layout.BaseYaw(localSeat) + 90f,
|
||||
0f),
|
||||
FaceUp = true,
|
||||
Rested = false,
|
||||
});
|
||||
|
|
@ -617,8 +794,10 @@ namespace Vanguard.Board3D
|
|||
Ref = BoardCardRef.Trigger(player.Index, i, card),
|
||||
Art = ResolveArt(card.PrintingId),
|
||||
Position = _layout.TriggerPosition(localSeat, i, count),
|
||||
// Карты проверки всегда развёрнуты к локальному игроку.
|
||||
BaseRotation = Quaternion.identity,
|
||||
BaseRotation = Quaternion.Euler(
|
||||
0f,
|
||||
_layout.BaseYaw(localSeat),
|
||||
0f),
|
||||
FaceUp = true,
|
||||
Rested = false,
|
||||
});
|
||||
|
|
@ -811,15 +990,15 @@ namespace Vanguard.Board3D
|
|||
|
||||
if (player == null)
|
||||
{
|
||||
deck.SetCount(0, null, "DECK", Color.white);
|
||||
drop.SetCount(0, null, "DROP", Color.white);
|
||||
deck.SetCount(0, null, _config.showZoneLabels ? "DECK" : string.Empty, Color.white);
|
||||
drop.SetCount(0, null, _config.showZoneLabels ? "DROP" : string.Empty, Color.white);
|
||||
return;
|
||||
}
|
||||
|
||||
deck.SetCount(
|
||||
player.DeckCount,
|
||||
null,
|
||||
"DECK",
|
||||
_config.showZoneLabels ? "DECK" : string.Empty,
|
||||
new Color(0.20f, 0.34f, 0.60f));
|
||||
|
||||
var topDrop = player.Drop != null && player.Drop.Count > 0
|
||||
|
|
@ -828,7 +1007,7 @@ namespace Vanguard.Board3D
|
|||
drop.SetCount(
|
||||
player.Drop?.Count ?? 0,
|
||||
topDrop != null ? ResolveArt(topDrop.PrintingId).Front : null,
|
||||
"DROP",
|
||||
_config.showZoneLabels ? "DROP" : string.Empty,
|
||||
new Color(0.30f, 0.30f, 0.40f));
|
||||
}
|
||||
|
||||
|
|
@ -863,6 +1042,14 @@ namespace Vanguard.Board3D
|
|||
|
||||
private void Update()
|
||||
{
|
||||
if (_boardLockReleaseAt > 0f
|
||||
&& (!AnyCardAnimating()
|
||||
|| Time.unscaledTime >= _boardLockReleaseAt))
|
||||
{
|
||||
_boardLockReleaseAt = -1f;
|
||||
_inputLock?.Release("board");
|
||||
}
|
||||
|
||||
UpdateHandView();
|
||||
|
||||
var delta = Time.unscaledDeltaTime;
|
||||
|
|
@ -872,6 +1059,16 @@ namespace Vanguard.Board3D
|
|||
card.Tick(delta);
|
||||
}
|
||||
|
||||
_attackArrow?.Tick(delta);
|
||||
UpdateAttackArrowPoints();
|
||||
|
||||
if (_inputLock != null && _inputLock.IsLocked)
|
||||
{
|
||||
if (_mode != PointerMode.None || _dragging)
|
||||
CancelPointer();
|
||||
return;
|
||||
}
|
||||
|
||||
HandleRightClick();
|
||||
HandlePointer();
|
||||
}
|
||||
|
|
@ -1185,13 +1382,13 @@ namespace Vanguard.Board3D
|
|||
if (pooled == null)
|
||||
continue;
|
||||
pooled.gameObject.SetActive(true);
|
||||
return pooled;
|
||||
return ConfigureCardSpeeds(pooled);
|
||||
}
|
||||
|
||||
if (_config.cardPrefab != null)
|
||||
{
|
||||
var instance = Instantiate(_config.cardPrefab, _cardsRoot);
|
||||
return instance;
|
||||
return ConfigureCardSpeeds(instance);
|
||||
}
|
||||
|
||||
if (_cardsRoot == null)
|
||||
|
|
@ -1200,7 +1397,19 @@ namespace Vanguard.Board3D
|
|||
_cardsRoot.SetParent(transform, false);
|
||||
}
|
||||
|
||||
return Card3D.Create(_cardsRoot, _config);
|
||||
return ConfigureCardSpeeds(Card3D.Create(_cardsRoot, _config));
|
||||
}
|
||||
|
||||
private Card3D ConfigureCardSpeeds(Card3D card)
|
||||
{
|
||||
if (card != null && _animationConfig != null)
|
||||
{
|
||||
card.SetAnimationSpeeds(
|
||||
_animationConfig.cardMoveSpeed,
|
||||
_animationConfig.cardRotateSpeed);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
private void ReleaseCard(Card3D card)
|
||||
|
|
@ -1289,6 +1498,29 @@ namespace Vanguard.Board3D
|
|||
{
|
||||
if (_instance == this)
|
||||
_instance = null;
|
||||
|
||||
_attackArrow?.Dispose();
|
||||
ReleaseBoardLock();
|
||||
}
|
||||
|
||||
private void ReleaseBoardLock()
|
||||
{
|
||||
if (_boardLockReleaseAt <= 0f)
|
||||
return;
|
||||
|
||||
_boardLockReleaseAt = -1f;
|
||||
_inputLock?.Release("board");
|
||||
}
|
||||
|
||||
private bool AnyCardAnimating()
|
||||
{
|
||||
foreach (var card in _cards.Values)
|
||||
{
|
||||
if (card != null && card.IsAnimating)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class HandCardBinding
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ namespace Vanguard.UI
|
|||
|
||||
private readonly MetaService _meta;
|
||||
private readonly Action _onExit;
|
||||
private readonly AnimationConfig _animationConfig;
|
||||
private readonly InputLockService _inputLock;
|
||||
private bool _feedbackLockHeld;
|
||||
private readonly Dictionary<string, CardCatalogItemDto> _catalog =
|
||||
new Dictionary<string, CardCatalogItemDto>(StringComparer.Ordinal);
|
||||
private bool _catalogLoading;
|
||||
|
|
@ -79,7 +82,10 @@ namespace Vanguard.UI
|
|||
private OverlayNavigationService _overlays;
|
||||
private ZoneBlockStripView _selfZones;
|
||||
private ZoneBlockStripView _opponentZones;
|
||||
private CardActionMenuView _cardMenu;
|
||||
private RectTransform _phasePanel;
|
||||
private RectTransform _combatStats;
|
||||
private Text _combatStatsText;
|
||||
private bool _autoFinishMulligan;
|
||||
private float _autoFinishMulliganAt;
|
||||
private bool _autoEndBattleArmed;
|
||||
|
|
@ -89,6 +95,9 @@ namespace Vanguard.UI
|
|||
{
|
||||
_meta = meta;
|
||||
_onExit = onExit;
|
||||
_animationConfig = AnimationConfig.LoadOrDefault();
|
||||
_inputLock = new InputLockService();
|
||||
_inputLock.Changed += OnInputLockChanged;
|
||||
|
||||
_root = UiKit.CreatePanel(parent, "BattleScreen", Background);
|
||||
var rootImage = _root.GetComponent<Image>();
|
||||
|
|
@ -187,8 +196,12 @@ namespace Vanguard.UI
|
|||
|
||||
BuildOverlaysAndZones();
|
||||
BuildPhasePanel();
|
||||
BuildCombatStats();
|
||||
_cardMenu = new CardActionMenuView();
|
||||
_cardMenu.Build(_root);
|
||||
|
||||
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
|
||||
_board3D.ConfigureRuntime(_animationConfig, _inputLock);
|
||||
_board3D.ArtProvider = ResolveBoardArt;
|
||||
_board3D.CardClicked += OnBoard3DCardClicked;
|
||||
_board3D.CardRightClicked += OnBoard3DCardRightClicked;
|
||||
|
|
@ -223,6 +236,8 @@ namespace Vanguard.UI
|
|||
_autoFinishMulliganAt = 0f;
|
||||
_autoEndBattleArmed = false;
|
||||
_autoEndBattleAt = 0f;
|
||||
_inputLock.ReleaseAll();
|
||||
_feedbackLockHeld = false;
|
||||
_logLines.Clear();
|
||||
RebuildLog();
|
||||
_feedbackQueue.Clear();
|
||||
|
|
@ -231,6 +246,7 @@ namespace Vanguard.UI
|
|||
_feedback.gameObject.SetActive(false);
|
||||
_flash.gameObject.SetActive(false);
|
||||
_skipFeedback.gameObject.SetActive(false);
|
||||
_combatStats?.gameObject.SetActive(false);
|
||||
_board3D?.Clear();
|
||||
_board3D?.SetVisible(true);
|
||||
_overlays?.CloseAll();
|
||||
|
|
@ -256,14 +272,18 @@ namespace Vanguard.UI
|
|||
_ = connection.DisposeAsync().AsTask();
|
||||
}
|
||||
|
||||
_view = null;
|
||||
_root.gameObject.SetActive(false);
|
||||
_result.gameObject.SetActive(false);
|
||||
_feedback.gameObject.SetActive(false);
|
||||
_flash.gameObject.SetActive(false);
|
||||
_skipFeedback.gameObject.SetActive(false);
|
||||
_overlays?.CloseAll();
|
||||
_cardMenu?.Hide();
|
||||
_combatStats?.gameObject.SetActive(false);
|
||||
_board3D?.SetVisible(false);
|
||||
_view = null;
|
||||
ReleaseFeedbackLock();
|
||||
_inputLock.ReleaseAll();
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
|
|
@ -281,7 +301,7 @@ namespace Vanguard.UI
|
|||
|
||||
private void UpdateAutoPhases()
|
||||
{
|
||||
if (_view == null || _sending)
|
||||
if (_view == null || _sending || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||||
|
|
@ -356,9 +376,16 @@ namespace Vanguard.UI
|
|||
{
|
||||
_feedbackPlaying = false;
|
||||
_skipFeedback.gameObject.SetActive(false);
|
||||
ReleaseFeedbackLock();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_feedbackLockHeld)
|
||||
{
|
||||
_feedbackLockHeld = true;
|
||||
_inputLock.Acquire("feedback");
|
||||
}
|
||||
|
||||
var step = _feedbackQueue.Dequeue();
|
||||
_feedbackPlaying = true;
|
||||
_feedback.text = step.Text;
|
||||
|
|
@ -376,6 +403,32 @@ namespace Vanguard.UI
|
|||
}
|
||||
}
|
||||
|
||||
private void ReleaseFeedbackLock()
|
||||
{
|
||||
if (!_feedbackLockHeld)
|
||||
return;
|
||||
|
||||
_feedbackLockHeld = false;
|
||||
_inputLock.Release("feedback");
|
||||
}
|
||||
|
||||
private void OnInputLockChanged(bool locked)
|
||||
{
|
||||
if (_view == null)
|
||||
return;
|
||||
|
||||
RebuildIntents(_view);
|
||||
RebuildPhaseControls(_view);
|
||||
}
|
||||
|
||||
private void OnOverlayOpenChanged(bool open)
|
||||
{
|
||||
if (open)
|
||||
_inputLock.Acquire("overlay");
|
||||
else
|
||||
_inputLock.Release("overlay");
|
||||
}
|
||||
|
||||
private void UpdateFeedback()
|
||||
{
|
||||
if (!_feedbackPlaying)
|
||||
|
|
@ -407,6 +460,7 @@ namespace Vanguard.UI
|
|||
_feedback.gameObject.SetActive(false);
|
||||
_flash.gameObject.SetActive(false);
|
||||
_skipFeedback.gameObject.SetActive(false);
|
||||
ReleaseFeedbackLock();
|
||||
}
|
||||
|
||||
private readonly struct FeedbackStep
|
||||
|
|
@ -598,46 +652,46 @@ namespace Vanguard.UI
|
|||
case AttackDeclaredView attack:
|
||||
ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot)
|
||||
+ " → " + SlotName(attack.TargetSlot),
|
||||
new Color(1f, 0.82f, 0.35f), 1.2f);
|
||||
new Color(1f, 0.82f, 0.35f), _animationConfig.attackDeclare);
|
||||
break;
|
||||
case GuardAddedView guard:
|
||||
ShowFeedback("GUARD " + CardName(guard.PrintingId)
|
||||
+ " +" + guard.Shield,
|
||||
new Color(0.45f, 0.82f, 1f), 1.9f);
|
||||
new Color(0.45f, 0.82f, 1f), _animationConfig.guard);
|
||||
break;
|
||||
case DriveCheckedView drive:
|
||||
ShowFeedback("DRIVE " + CardName(drive.PrintingId)
|
||||
+ " · " + TriggerShort(drive.Trigger.ToString()),
|
||||
new Color(0.72f, 0.88f, 1f), 1.5f);
|
||||
new Color(0.72f, 0.88f, 1f), _animationConfig.driveCheck);
|
||||
break;
|
||||
case TriggerResolvedView trigger:
|
||||
ShowFeedback("TRIGGER " + CardName(trigger.PrintingId),
|
||||
new Color(0.72f, 0.88f, 1f), 1.4f);
|
||||
new Color(0.72f, 0.88f, 1f), _animationConfig.trigger);
|
||||
break;
|
||||
case BattleResolvedView battle:
|
||||
ShowFeedback(battle.Hit ? "HIT" : "BLOCK",
|
||||
battle.Hit ? new Color(1f, 0.45f, 0.35f) : new Color(0.45f, 0.82f, 1f),
|
||||
1.3f);
|
||||
_animationConfig.battleResolved);
|
||||
break;
|
||||
case DamageTakenView damage:
|
||||
ShowFeedback("+" + damage.Amount + " DAMAGE",
|
||||
new Color(1f, 0.30f, 0.26f), 1.6f, flash: true);
|
||||
new Color(1f, 0.30f, 0.26f), _animationConfig.damage, flash: true);
|
||||
break;
|
||||
case HealedView:
|
||||
ShowFeedback("HEAL",
|
||||
new Color(0.45f, 0.95f, 0.62f), 1.4f, flash: true);
|
||||
new Color(0.45f, 0.95f, 0.62f), _animationConfig.heal, flash: true);
|
||||
break;
|
||||
case UnitRetiredView:
|
||||
ShowFeedback("RETIRE",
|
||||
new Color(1f, 0.55f, 0.30f), 1.2f);
|
||||
new Color(1f, 0.55f, 0.30f), _animationConfig.retireDuration);
|
||||
break;
|
||||
case InterceptedView intercept:
|
||||
ShowFeedback("INTERCEPT " + CardName(intercept.PrintingId),
|
||||
new Color(0.55f, 0.82f, 1f), 1.9f);
|
||||
new Color(0.55f, 0.82f, 1f), _animationConfig.intercept);
|
||||
break;
|
||||
case PerfectGuardView perfect:
|
||||
ShowFeedback("PERFECT GUARD " + CardName(perfect.SentinelPrintingId),
|
||||
new Color(0.60f, 0.82f, 1f), 2.0f);
|
||||
new Color(0.60f, 0.82f, 1f), _animationConfig.perfectGuard);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -734,6 +788,9 @@ namespace Vanguard.UI
|
|||
if (_sending || _connection == null || _cts == null || _view == null)
|
||||
return;
|
||||
|
||||
if (_inputLock.IsLocked && intent is not ConcedeIntent)
|
||||
return;
|
||||
|
||||
if (_abilityDamageIndex >= 0)
|
||||
{
|
||||
if (intent is ActivateAbilityIntent activate)
|
||||
|
|
@ -850,8 +907,10 @@ namespace Vanguard.UI
|
|||
if (_view == null)
|
||||
return;
|
||||
|
||||
_cardMenu?.Hide();
|
||||
_board3D?.SetSelfPlayer(_view.You);
|
||||
_board3D?.Render(_view, BuildBoardRenderState());
|
||||
UpdateCombatStats();
|
||||
UpdateZoneStrips();
|
||||
|
||||
RebuildIntents(_view);
|
||||
|
|
@ -965,9 +1024,18 @@ namespace Vanguard.UI
|
|||
|
||||
private void OnBoard3DCardClicked(BoardCardRef card)
|
||||
{
|
||||
if (_view == null || _sending)
|
||||
if (_view == null || _sending || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
var targetSelection = _pendingIntents.Count > 0
|
||||
|| IsMulliganSelecting()
|
||||
|| IsDamageChoice()
|
||||
|| IsAbilityDamageTargeting()
|
||||
|| _view.Trigger != null
|
||||
|| _view.Choice != null
|
||||
|| _view.Ability != null
|
||||
|| _view.Order != null;
|
||||
|
||||
switch (card.Zone)
|
||||
{
|
||||
case BoardZone.Hand:
|
||||
|
|
@ -982,13 +1050,24 @@ namespace Vanguard.UI
|
|||
break;
|
||||
|
||||
case BoardZone.Vanguard:
|
||||
OnBoardTargetClicked(new BoardTarget(
|
||||
card.Player, -1, card.PrintingId, card.InstanceId, false));
|
||||
break;
|
||||
|
||||
case BoardZone.RearGuard:
|
||||
OnBoardTargetClicked(new BoardTarget(
|
||||
card.Player, card.Slot, card.PrintingId, card.InstanceId, false));
|
||||
if (targetSelection)
|
||||
{
|
||||
OnBoardTargetClicked(new BoardTarget(
|
||||
card.Player,
|
||||
card.Zone == BoardZone.Vanguard ? -1 : card.Slot,
|
||||
card.PrintingId,
|
||||
card.InstanceId,
|
||||
false));
|
||||
}
|
||||
else if (card.Player == _view.You)
|
||||
{
|
||||
OpenCardMenu(card);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenCardDetails(card);
|
||||
}
|
||||
break;
|
||||
|
||||
case BoardZone.Damage:
|
||||
|
|
@ -1000,14 +1079,144 @@ namespace Vanguard.UI
|
|||
break;
|
||||
|
||||
case BoardZone.Guardian:
|
||||
SetStatus("Карта в круге защиты.", Muted);
|
||||
case BoardZone.Trigger:
|
||||
OpenCardDetails(card);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCardMenu(BoardCardRef card)
|
||||
{
|
||||
if (_view == null || _board3D == null)
|
||||
return;
|
||||
|
||||
var target = new BoardTarget(
|
||||
card.Player,
|
||||
card.Zone == BoardZone.Vanguard ? -1 : card.Slot,
|
||||
card.PrintingId,
|
||||
card.InstanceId,
|
||||
false);
|
||||
|
||||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||||
var sources = legal
|
||||
.Where(intent => TryGetSource(intent, out var source) && source.Matches(target))
|
||||
.ToList();
|
||||
|
||||
var actions = new List<CardActionMenuItem>();
|
||||
var attacks = sources.OfType<AttackIntent>().ToList();
|
||||
foreach (var group in attacks.GroupBy(attack => attack.BoosterSlot))
|
||||
{
|
||||
var pending = group.ToList();
|
||||
var booster = group.Key;
|
||||
var label = booster < 0
|
||||
? "Атака"
|
||||
: "Атака с поддержкой: " + CardName(
|
||||
CardAt(_view.You, booster)?.PrintingId ?? string.Empty);
|
||||
actions.Add(new CardActionMenuItem(label, () =>
|
||||
{
|
||||
BeginTargetSelection(pending, "Выберите цель атаки.");
|
||||
}));
|
||||
}
|
||||
|
||||
var moves = sources.OfType<MoveIntent>().ToList();
|
||||
if (moves.Count > 0)
|
||||
{
|
||||
actions.Add(new CardActionMenuItem("Переместить", () =>
|
||||
{
|
||||
BeginTargetSelection(moves, "Выберите круг для перемещения.");
|
||||
}));
|
||||
}
|
||||
|
||||
foreach (var intent in sources)
|
||||
{
|
||||
if (intent is AttackIntent or MoveIntent)
|
||||
continue;
|
||||
|
||||
var captured = intent;
|
||||
actions.Add(new CardActionMenuItem(
|
||||
Label(captured),
|
||||
() => OnIntentClicked(captured)));
|
||||
}
|
||||
|
||||
actions.Add(new CardActionMenuItem("Подробнее", () => OpenCardDetails(card)));
|
||||
|
||||
if (!_board3D.TryGetCardScreenPosition(card, out var screen))
|
||||
return;
|
||||
|
||||
_inputLock.Acquire("card-menu");
|
||||
_cardMenu.Show(
|
||||
screen,
|
||||
CardName(card.PrintingId),
|
||||
actions,
|
||||
() => _inputLock.Release("card-menu"));
|
||||
}
|
||||
|
||||
private void BeginTargetSelection(
|
||||
IEnumerable<IntentPayload> intents,
|
||||
string prompt)
|
||||
{
|
||||
var list = intents.ToList();
|
||||
if (list.Count == 0)
|
||||
return;
|
||||
|
||||
_pendingIntents.Clear();
|
||||
_pendingIntents.AddRange(list);
|
||||
RebuildHighlights();
|
||||
RebuildIntents(_view);
|
||||
SetStatus(prompt, new Color(0.98f, 0.82f, 0.42f));
|
||||
}
|
||||
|
||||
private void OpenCardDetails(BoardCardRef card)
|
||||
{
|
||||
if (_view == null || _overlays == null)
|
||||
return;
|
||||
|
||||
var view = FindCardView(card);
|
||||
var entry = view != null
|
||||
? new CardListEntry(view)
|
||||
: new CardListEntry(null, true, "Закрытая карта");
|
||||
_overlays.OpenCardDetail(entry);
|
||||
}
|
||||
|
||||
private CardView FindCardView(BoardCardRef card)
|
||||
{
|
||||
if (_view == null)
|
||||
return null;
|
||||
|
||||
var player = _view.Players.FirstOrDefault(item => item.Index == card.Player);
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
switch (card.Zone)
|
||||
{
|
||||
case BoardZone.Vanguard:
|
||||
return player.Vanguard;
|
||||
case BoardZone.RearGuard:
|
||||
return player.RearGuards != null
|
||||
&& card.Slot >= 0
|
||||
&& card.Slot < player.RearGuards.Length
|
||||
? player.RearGuards[card.Slot]
|
||||
: null;
|
||||
case BoardZone.Hand:
|
||||
return player.Hand?.FirstOrDefault(item => item.InstanceId == card.InstanceId);
|
||||
case BoardZone.Guardian:
|
||||
return player.Guardian?.FirstOrDefault(item => item.InstanceId == card.InstanceId);
|
||||
case BoardZone.Trigger:
|
||||
return player.Trigger?.FirstOrDefault(item => item.InstanceId == card.InstanceId);
|
||||
case BoardZone.Damage:
|
||||
return player.FaceUpDamage != null
|
||||
&& card.Index >= 0
|
||||
&& card.Index < player.FaceUpDamage.Count
|
||||
? player.FaceUpDamage[card.Index]
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBoard3DSlotClicked(BoardCardRef slot)
|
||||
{
|
||||
if (_view == null || _sending)
|
||||
if (_view == null || _sending || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
if (slot.Zone == BoardZone.Guardian)
|
||||
|
|
@ -1018,7 +1227,7 @@ namespace Vanguard.UI
|
|||
|
||||
private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target)
|
||||
{
|
||||
if (_view == null || _sending)
|
||||
if (_view == null || _sending || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
var legal = _view.LegalIntents ?? new List<IntentPayload>();
|
||||
|
|
@ -1079,7 +1288,7 @@ namespace Vanguard.UI
|
|||
|
||||
private void OnBoard3DPileRightClicked(BoardCardRef pile)
|
||||
{
|
||||
if (_view == null)
|
||||
if (_view == null || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
if (pile.Zone == BoardZone.Soul)
|
||||
|
|
@ -1088,7 +1297,7 @@ namespace Vanguard.UI
|
|||
|
||||
private void OnBoard3DCardRightClicked(BoardCardRef card)
|
||||
{
|
||||
if (_view == null)
|
||||
if (_view == null || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
if (card.Zone == BoardZone.Vanguard)
|
||||
|
|
@ -1097,7 +1306,7 @@ namespace Vanguard.UI
|
|||
|
||||
private void OnBoard3DPileClicked(BoardCardRef pile)
|
||||
{
|
||||
if (_view == null)
|
||||
if (_view == null || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
if (pile.Zone == BoardZone.Soul)
|
||||
|
|
@ -1171,6 +1380,7 @@ namespace Vanguard.UI
|
|||
PhaseLabel(captured),
|
||||
() => OnIntentClicked(captured),
|
||||
14);
|
||||
button.interactable = !_inputLock.IsLocked;
|
||||
UiKit.SetPreferredHeight(button, 40f);
|
||||
}
|
||||
}
|
||||
|
|
@ -1191,6 +1401,7 @@ namespace Vanguard.UI
|
|||
private void BuildOverlaysAndZones()
|
||||
{
|
||||
_overlays = new OverlayNavigationService(_root, ResolveCatalogItem);
|
||||
_overlays.OpenChanged += OnOverlayOpenChanged;
|
||||
|
||||
_selfZones = new ZoneBlockStripView();
|
||||
_selfZones.Build(
|
||||
|
|
@ -1214,6 +1425,88 @@ namespace Vanguard.UI
|
|||
OpenZone(zone, _view != null ? 1 - _view.You : 1);
|
||||
}
|
||||
|
||||
private void BuildCombatStats()
|
||||
{
|
||||
_combatStats = UiKit.CreatePanel(
|
||||
_root,
|
||||
"CombatStats",
|
||||
new Color(0.03f, 0.045f, 0.07f, 0.9f));
|
||||
_combatStats.anchorMin = new Vector2(0.5f, 1f);
|
||||
_combatStats.anchorMax = new Vector2(0.5f, 1f);
|
||||
_combatStats.pivot = new Vector2(0.5f, 1f);
|
||||
_combatStats.anchoredPosition = new Vector2(0f, -18f);
|
||||
_combatStats.sizeDelta = new Vector2(620f, 48f);
|
||||
var statsImage = _combatStats.GetComponent<Image>();
|
||||
if (statsImage != null)
|
||||
statsImage.raycastTarget = false;
|
||||
|
||||
_combatStatsText = UiKit.CreateText(
|
||||
_combatStats,
|
||||
"Text",
|
||||
string.Empty,
|
||||
20,
|
||||
new Color(0.9f, 0.94f, 1f),
|
||||
TextAnchor.MiddleCenter);
|
||||
UiKit.Stretch(_combatStatsText.rectTransform);
|
||||
_combatStatsText.fontStyle = FontStyle.Bold;
|
||||
UiKit.MakeLiteral(_combatStatsText);
|
||||
_combatStats.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private void UpdateCombatStats()
|
||||
{
|
||||
if (_combatStats == null || _combatStatsText == null)
|
||||
return;
|
||||
|
||||
var battle = _view?.Battle;
|
||||
if (battle == null)
|
||||
{
|
||||
_combatStats.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var defenderIndex = 1 - battle.AttackerPlayer;
|
||||
var attacker = BattleCard(battle.AttackerPlayer, battle.AttackerSlot);
|
||||
var defender = battle.InterceptorSlot >= 0
|
||||
? BattleCard(defenderIndex, battle.InterceptorSlot)
|
||||
: BattleCard(defenderIndex, battle.TargetSlot);
|
||||
defender ??= BattleCard(defenderIndex, battle.TargetSlot);
|
||||
|
||||
if (attacker == null || defender == null)
|
||||
{
|
||||
_combatStats.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var attackPower = attacker.Power;
|
||||
var shield = defender.Power + battle.GuardShield;
|
||||
var text = battle.Nullified
|
||||
? "POWER " + attackPower + " · NULLIFIED"
|
||||
: "POWER " + attackPower + " vs SHIELD " + shield;
|
||||
_combatStatsText.text = text;
|
||||
_combatStatsText.color = battle.Nullified
|
||||
? new Color(0.7f, 0.76f, 0.9f)
|
||||
: attackPower >= shield
|
||||
? new Color(1f, 0.72f, 0.32f)
|
||||
: new Color(0.52f, 0.82f, 1f);
|
||||
_combatStats.gameObject.SetActive(true);
|
||||
}
|
||||
|
||||
private CardView BattleCard(int playerIndex, int slot)
|
||||
{
|
||||
var player = _view?.Players.FirstOrDefault(item => item.Index == playerIndex);
|
||||
if (player == null)
|
||||
return null;
|
||||
|
||||
if (slot < 0)
|
||||
return player.Vanguard;
|
||||
|
||||
return player.RearGuards != null
|
||||
&& slot < player.RearGuards.Length
|
||||
? player.RearGuards[slot]
|
||||
: null;
|
||||
}
|
||||
|
||||
private void UpdateZoneStrips()
|
||||
{
|
||||
if (_view == null)
|
||||
|
|
@ -1241,6 +1534,9 @@ namespace Vanguard.UI
|
|||
_catalog.TryGetValue(printingId, out var item) ? item : null;
|
||||
private void OnDamageClicked(int displayIndex)
|
||||
{
|
||||
if (_inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
if (IsDamageChoice())
|
||||
{
|
||||
if (_view?.Choice?.Options == null)
|
||||
|
|
@ -1317,7 +1613,8 @@ namespace Vanguard.UI
|
|||
|
||||
private void SendMulligan()
|
||||
{
|
||||
if (!IsMulliganSelecting() || _mulliganSelected.Count == 0)
|
||||
if (!IsMulliganSelecting() || _mulliganSelected.Count == 0
|
||||
|| _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
var indexes = _mulliganSelected.OrderBy(index => index).ToList();
|
||||
|
|
@ -1330,7 +1627,7 @@ namespace Vanguard.UI
|
|||
|
||||
private void OnBoardTargetClicked(BoardTarget target)
|
||||
{
|
||||
if (_view == null || _sending)
|
||||
if (_view == null || _sending || _inputLock.IsLocked)
|
||||
return;
|
||||
|
||||
if (IsMulliganSelecting() && target.IsHand && target.Player == _view.You)
|
||||
|
|
@ -1787,6 +2084,7 @@ namespace Vanguard.UI
|
|||
Label(captured),
|
||||
() => OnIntentClicked(captured),
|
||||
16);
|
||||
button.interactable = !_inputLock.IsLocked;
|
||||
UiKit.SetPreferredHeight(button, 46f);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ namespace Vanguard.UI
|
|||
[" карт."] = " cards.",
|
||||
[" пыли)"] = " dust)",
|
||||
["Атака "] = "Attack ",
|
||||
["Атака"] = "Attack",
|
||||
["Атака с поддержкой: "] = "Boosted attack: ",
|
||||
["Переместить"] = "Move",
|
||||
["Подробнее"] = "Details",
|
||||
["Нет доступных действий"] = "No available actions",
|
||||
["Выберите цель атаки."] = "Choose an attack target.",
|
||||
["Выберите круг для перемещения."] = "Choose a circle to move to.",
|
||||
["В меню"] = "To menu",
|
||||
["Готово"] = "Done",
|
||||
["Журнал"] = "Log",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Vanguard.UI.Overlays
|
||||
{
|
||||
public sealed class CardActionMenuItem
|
||||
{
|
||||
public CardActionMenuItem(string label, Action action, bool enabled = true)
|
||||
{
|
||||
Label = label;
|
||||
Action = action;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
public string Label { get; }
|
||||
public Action Action { get; }
|
||||
public bool Enabled { get; }
|
||||
}
|
||||
|
||||
/// <summary>Экранное контекстное меню над картой.</summary>
|
||||
public sealed class CardActionMenuView
|
||||
{
|
||||
private RectTransform _root;
|
||||
private RectTransform _popup;
|
||||
private Action _onClose;
|
||||
|
||||
public bool IsVisible => _root != null && _root.gameObject.activeSelf;
|
||||
|
||||
public void Build(Transform parent)
|
||||
{
|
||||
_root = UiKit.CreatePanel(parent, "CardActionMenu", new Color(0f, 0f, 0f, 0.01f));
|
||||
var image = _root.GetComponent<Image>();
|
||||
image.raycastTarget = true;
|
||||
|
||||
var backdrop = _root.gameObject.AddComponent<Button>();
|
||||
backdrop.targetGraphic = image;
|
||||
backdrop.onClick.AddListener(Hide);
|
||||
|
||||
_popup = UiKit.CreatePanel(_root, "Popup", new Color(0.045f, 0.06f, 0.09f, 0.98f));
|
||||
_popup.anchorMin = new Vector2(0f, 1f);
|
||||
_popup.anchorMax = new Vector2(0f, 1f);
|
||||
_popup.pivot = new Vector2(0f, 1f);
|
||||
_popup.sizeDelta = new Vector2(250f, 380f);
|
||||
UiKit.AddVerticalLayout(_popup, 8, 4);
|
||||
|
||||
_root.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
public void Show(
|
||||
Vector2 screenPosition,
|
||||
string title,
|
||||
IReadOnlyList<CardActionMenuItem> items,
|
||||
Action onClose)
|
||||
{
|
||||
if (_root == null)
|
||||
return;
|
||||
|
||||
_onClose = onClose;
|
||||
ClearChildren(_popup);
|
||||
|
||||
var heading = UiKit.CreateText(
|
||||
_popup,
|
||||
"Title",
|
||||
title,
|
||||
15,
|
||||
new Color(0.86f, 0.91f, 1f),
|
||||
TextAnchor.MiddleLeft);
|
||||
heading.fontStyle = FontStyle.Bold;
|
||||
UiKit.MakeLiteral(heading);
|
||||
UiKit.SetPreferredHeight(heading, 26f);
|
||||
|
||||
if (items == null || items.Count == 0)
|
||||
{
|
||||
var empty = UiKit.CreateText(
|
||||
_popup,
|
||||
"Empty",
|
||||
"Нет доступных действий",
|
||||
13,
|
||||
new Color(0.62f, 0.68f, 0.82f));
|
||||
UiKit.SetPreferredHeight(empty, 34f);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var button = UiKit.CreateButton(
|
||||
_popup,
|
||||
"Action_" + item.Label,
|
||||
item.Label,
|
||||
() =>
|
||||
{
|
||||
var action = item.Action;
|
||||
Hide();
|
||||
action?.Invoke();
|
||||
},
|
||||
14);
|
||||
button.interactable = item.Enabled;
|
||||
UiKit.SetPreferredHeight(button, 36f);
|
||||
}
|
||||
}
|
||||
|
||||
_root.SetAsLastSibling();
|
||||
_root.gameObject.SetActive(true);
|
||||
|
||||
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_root,
|
||||
screenPosition,
|
||||
null,
|
||||
out var local))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = _root.rect;
|
||||
var fromLeft = local.x + rect.width * 0.5f;
|
||||
var fromTop = rect.height * 0.5f - local.y;
|
||||
var x = Mathf.Clamp(
|
||||
fromLeft + 18f,
|
||||
8f,
|
||||
Mathf.Max(8f, rect.width - 258f));
|
||||
var y = Mathf.Clamp(
|
||||
-fromTop - 18f,
|
||||
-Mathf.Max(8f, rect.height - 388f),
|
||||
-8f);
|
||||
_popup.anchoredPosition = new Vector2(x, y);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (_root == null || !_root.gameObject.activeSelf)
|
||||
return;
|
||||
|
||||
_root.gameObject.SetActive(false);
|
||||
var callback = _onClose;
|
||||
_onClose = null;
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
private static void ClearChildren(Transform parent)
|
||||
{
|
||||
for (var i = parent.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var child = parent.GetChild(i).gameObject;
|
||||
child.SetActive(false);
|
||||
if (Application.isPlaying)
|
||||
UnityEngine.Object.Destroy(child);
|
||||
else
|
||||
UnityEngine.Object.DestroyImmediate(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 11b04f902000e5049a3dbc457cd1f570
|
||||
|
|
@ -46,8 +46,25 @@ namespace Vanguard.UI.Overlays
|
|||
_detail.Hide();
|
||||
_list.Show(request, OpenDetail, CloseAll);
|
||||
_root.SetAsLastSibling();
|
||||
var wasOpen = _root.gameObject.activeSelf;
|
||||
_root.gameObject.SetActive(true);
|
||||
OpenChanged?.Invoke(true);
|
||||
if (!wasOpen)
|
||||
OpenChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
public void OpenCardDetail(CardListEntry entry)
|
||||
{
|
||||
if (entry == null)
|
||||
return;
|
||||
|
||||
_currentRequest = null;
|
||||
_list.Hide();
|
||||
_detail.Show(entry, CloseAll);
|
||||
_root.SetAsLastSibling();
|
||||
var wasOpen = _root.gameObject.activeSelf;
|
||||
_root.gameObject.SetActive(true);
|
||||
if (!wasOpen)
|
||||
OpenChanged?.Invoke(true);
|
||||
}
|
||||
|
||||
private void OpenDetail(CardListEntry entry)
|
||||
|
|
@ -72,11 +89,13 @@ namespace Vanguard.UI.Overlays
|
|||
|
||||
public void CloseAll()
|
||||
{
|
||||
var wasOpen = _root != null && _root.gameObject.activeSelf;
|
||||
_list?.Hide();
|
||||
_detail?.Hide();
|
||||
_root.gameObject.SetActive(false);
|
||||
_currentRequest = null;
|
||||
OpenChanged?.Invoke(false);
|
||||
if (wasOpen)
|
||||
OpenChanged?.Invoke(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
|
|||
|
|
@ -83,6 +83,79 @@ namespace Vanguard.Tests
|
|||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Animation_Config_Asset_Is_Loaded_From_Resources()
|
||||
{
|
||||
var asset = Resources.Load<Vanguard.Battle.AnimationConfig>("AnimationConfig");
|
||||
Assert.IsNotNull(asset);
|
||||
Assert.Greater(asset.cardMoveDuration, 0f);
|
||||
Assert.Greater(asset.arrowShowDuration, 0f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Input_Lock_Returns_After_Last_Reason()
|
||||
{
|
||||
var lockService = new Vanguard.Battle.InputLockService();
|
||||
var changes = new List<bool>();
|
||||
lockService.Changed += changes.Add;
|
||||
|
||||
lockService.Acquire("board");
|
||||
lockService.Acquire("feedback");
|
||||
Assert.IsTrue(lockService.IsLocked);
|
||||
|
||||
lockService.Release("board");
|
||||
Assert.IsTrue(lockService.IsLocked);
|
||||
|
||||
lockService.Release("feedback");
|
||||
Assert.IsFalse(lockService.IsLocked);
|
||||
CollectionAssert.AreEqual(new[] { true, false }, changes);
|
||||
|
||||
lockService.Acquire("overlay");
|
||||
lockService.Acquire("overlay");
|
||||
lockService.Release("overlay");
|
||||
Assert.IsTrue(lockService.IsLocked);
|
||||
lockService.Release("overlay");
|
||||
Assert.IsFalse(lockService.IsLocked);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Card_Action_Menu_Invokes_Action_And_Closes()
|
||||
{
|
||||
var canvas = UiKit.CreateCanvas("Menu Test Canvas");
|
||||
var menu = new CardActionMenuView();
|
||||
menu.Build(canvas.transform);
|
||||
var invoked = false;
|
||||
var closed = false;
|
||||
try
|
||||
{
|
||||
menu.Show(
|
||||
new Vector2(200f, 200f),
|
||||
"Карта",
|
||||
new List<CardActionMenuItem>
|
||||
{
|
||||
new CardActionMenuItem("Тест", () => invoked = true),
|
||||
},
|
||||
() => closed = true);
|
||||
|
||||
Assert.IsTrue(menu.IsVisible);
|
||||
var button = Resources.FindObjectsOfTypeAll<Button>()
|
||||
.FirstOrDefault(item => item.name == "Action_Тест");
|
||||
Assert.IsNotNull(button);
|
||||
button.onClick.Invoke();
|
||||
Assert.IsTrue(invoked);
|
||||
Assert.IsTrue(closed);
|
||||
Assert.IsFalse(menu.IsVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (canvas != null)
|
||||
Object.DestroyImmediate(canvas.gameObject);
|
||||
var eventSystem = Object.FindAnyObjectByType<EventSystem>();
|
||||
if (eventSystem != null)
|
||||
Object.DestroyImmediate(eventSystem.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private static CardView Card(string id, int instanceId) => new CardView
|
||||
{
|
||||
PrintingId = id,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,32 @@ namespace Vanguard.Tests
|
|||
{
|
||||
public sealed class GameBoard3DTests
|
||||
{
|
||||
[Test]
|
||||
public void Board_Shows_Attack_Arrow_For_Active_Battle()
|
||||
{
|
||||
var config = PlaymatLayoutConfig.CreateDefault();
|
||||
var board = GameBoard3D.Create(null, config);
|
||||
try
|
||||
{
|
||||
board.SetVisible(true);
|
||||
var view = BuildView(you: 0);
|
||||
view.Battle = new BattleView
|
||||
{
|
||||
AttackerPlayer = 0,
|
||||
AttackerSlot = 3,
|
||||
TargetSlot = -1,
|
||||
GuardShield = 5000,
|
||||
};
|
||||
board.Render(view, new BoardRenderState());
|
||||
|
||||
Assert.IsTrue(board.AttackArrowVisible);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(board.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Board_Renders_Field_Hand_Guardian_Damage_And_Soul()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -911,3 +911,36 @@ Battle; Boot и общие сервисы остаются персистент
|
|||
- Дальше по плану: Phase 2 — `InputLockService` + `AnimationConfig` +
|
||||
контекстное меню; Phase 3 — Power/Shield и стрелка; Phase 4 — Guardian
|
||||
стопка, Trigger mirror, debug-подписи.
|
||||
|
||||
## 2026-10-23 — UX Phase 2–4: ввод, контекст, бой и раскладка
|
||||
|
||||
- `InputLockService` — reference-count по причинам (`feedback`, `board`,
|
||||
`overlay`, `card-menu`); ввод возвращается только после последней причины.
|
||||
`AnimationConfig` (ScriptableObject в `Resources/AnimationConfig.asset`):
|
||||
скорости карт, длительности фидбека/флипа/retire, хвост блокировки и
|
||||
стрелка. `Card3D` получает скорости из конфига, `GameBoard3D` блокирует
|
||||
доску на время снапа/переворота, `BattleView` — на время очереди событий
|
||||
и открытых оверлеев. Кнопки интентов/фаз и клики по столу не проходят,
|
||||
пока идёт анимация; «Пропустить анимации» остаётся доступной.
|
||||
- Контекстное меню `CardActionMenuView`: ЛКМ по своей карте V/R открывает
|
||||
список действий, отфильтрованных по `LegalIntents` (атака, атака с
|
||||
поддержкой, перемещение, способность, подробнее). Действия с целями
|
||||
переводят в режим выбора цели; карты противника и зоны открывают
|
||||
`CardDetailOverlay`/списки. Меню закрывается на новом снимке и держит
|
||||
`card-menu` в InputLock.
|
||||
- Боевая плашка `POWER X vs SHIELD Y` в верхней части экрана: сила
|
||||
атакующего против силы защищающегося (с учётом перехватчика) плюс
|
||||
`GuardShield`; пересчитывается на каждом снимке, `NULLIFIED` показывается
|
||||
отдельно. `AttackArrowView` рисует затухающую 3D-стрелку с наконечником от
|
||||
атакующего к цели; точки обновляются каждый кадр по трансформам карт,
|
||||
поэтому стрелка едет за анимацией.
|
||||
- Guardian-карты повёрнуты на 90° вокруг Y, уложены с перекрытием около 30%
|
||||
(spacing 0.62 при высоте 0.88), новые сверху за счёт микро-подъёма.
|
||||
Trigger-зона зеркальна по X и ориентации; 3D-подписи `DECK`/`DROP`/
|
||||
`TRIGGER` скрыты флагом `showZoneLabels` (по умолчанию false).
|
||||
- Проверки: клиент EditMode **26/26** (InputLock, контекстное меню, стрелка
|
||||
атаки, загрузка `AnimationConfig`), `SCENE_SMOKE success=True`,
|
||||
`PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
|
||||
- Дальше: по желанию вынести overlay-экраны и меню в prefab-ассеты; проверить
|
||||
итоговые позиции на реальном разрешении и, при необходимости, подкрутить
|
||||
`AnimationConfig`/`PlaymatLayoutConfig`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Vanguard ReMaster — состояние сессии
|
||||
|
||||
**Дата:** 2026-10-22
|
||||
**Дата:** 2026-10-23
|
||||
|
||||
**HEAD:** см. `git log -1`
|
||||
**Коротко:** сервер готов, клиент — рабочий каркас с 3D-сценами.
|
||||
|
|
@ -96,11 +96,21 @@
|
|||
- Маллиган: один раз на игрока, оба игрока; выбранные карты уходят в низ
|
||||
колоды, добираются новые, затем shuffle. Клик по руке выбирает карты.
|
||||
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
|
||||
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
|
||||
Guard/drive/intercept показывают имя карты и держатся 1.5–2 с, чтобы
|
||||
успеть прочитать защиту бота. 3D-карты плавно снапятся, поворачиваются
|
||||
в Rest и переворачиваются; подсветка целей и точки броска обновляются
|
||||
на каждом снимке.
|
||||
стола; ввод заблокирован до завершения через `InputLockService`, очередь
|
||||
событий + кнопка «Пропустить анимации». Длительности и скорости карт —
|
||||
`AnimationConfig` (`Resources/AnimationConfig`). Guard/drive/intercept
|
||||
показывают имя карты и держатся 1.5–2 с. 3D-карты плавно снапятся,
|
||||
поворачиваются в Rest и переворачиваются; подсветка целей и точки броска
|
||||
обновляются на каждом снимке.
|
||||
- Бой: динамическая плашка `POWER X vs SHIELD Y` (Power атакующего против
|
||||
Power защищающегося + GuardShield), пересчитывается на каждом снимке, в
|
||||
том числе после Guard. От атакующего к цели идёт затухающая 3D-стрелка,
|
||||
которая следует за картами. ЛКМ по своей карте V/R открывает контекстное
|
||||
меню с действиями из `LegalIntents` (атака/boost, перемещение, способность,
|
||||
подробнее); карты противника и зоны открывают детали/списки. ПКМ по VC —
|
||||
душа. Guardian-карты повёрнуты на 90° и уложены с перекрытием ~30%,
|
||||
новые сверху; Trigger-зона зеркальна у противника. 3D-подписи зон скрыты,
|
||||
включаются флагом `showZoneLabels`.
|
||||
- Реальные арты: `imageKey` из `/api/cards` + `CardImageService` ищет
|
||||
`Resources/CardArt/{imageKey}`. Скачаны все 124 арта. Импорт:
|
||||
`tools/import_card_art.py`.
|
||||
|
|
@ -152,11 +162,12 @@ curl http://127.0.0.1:18080/health
|
|||
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
||||
```
|
||||
|
||||
Клиентские проверки: **22/22** EditMode (сборка всех экранов, импорт арта,
|
||||
Клиентские проверки: **26/26** EditMode (сборка всех экранов, импорт арта,
|
||||
перевод статических/динамических строк, live-переключение языка, рендер
|
||||
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
|
||||
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
|
||||
CardListView/Detail, зоны и рука противника), `SCENE_SMOKE success=True`,
|
||||
CardListView/Detail, зоны и рука противника, InputLock, контекстное меню,
|
||||
стрелка атаки), `SCENE_SMOKE success=True`,
|
||||
`PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
|
||||
|
||||
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
|
||||
|
|
@ -189,6 +200,11 @@ client/Assets/Vanguard/Runtime/UI/Overlays/
|
|||
CardDetailOverlay.cs крупная карта и текст
|
||||
CardListModels.cs зоны и скрытые карты
|
||||
ZoneBlockStripView.cs кликабельные блоки зон
|
||||
CardActionMenuView.cs контекстное меню карты
|
||||
client/Assets/Vanguard/Runtime/Battle/
|
||||
AnimationConfig.cs длительности/скорости анимаций
|
||||
InputLockService.cs блокировка ввода по причинам
|
||||
GameConnection.cs
|
||||
client/Assets/Vanguard/Runtime/Board3D/
|
||||
Config/PlaymatLayoutConfig.cs ScriptableObject-раскладка
|
||||
Data/BoardTypes.cs зоны, круги, CardState3D, refs
|
||||
|
|
@ -196,10 +212,11 @@ client/Assets/Vanguard/Runtime/Board3D/
|
|||
Logic/BoardPlacementRules.cs Ride/Call/Move/Guard
|
||||
View/GameBoard3D.cs стол, круги, пул карт, drag&drop
|
||||
View/Card3D.cs карта: лицо/рубашка/торец, Rest, флип
|
||||
View/AttackArrowView.cs стрелка атаки с наконечником
|
||||
PlaymatSandbox.cs демо без сервера
|
||||
client/Assets/Vanguard/Resources/AnimationConfig.asset настройки анимаций
|
||||
client/Assets/Vanguard/Resources/CardArt/vanguard_back.png рубашка VF
|
||||
client/Assets/Vanguard/Scenes/GameBoard3D.unity песочница поля
|
||||
client/Assets/Vanguard/Runtime/Battle/GameConnection.cs
|
||||
client/Assets/Vanguard/Runtime/Api/ApiClient.cs + ApiContracts.cs
|
||||
client/Assets/Vanguard/Editor/SceneBuilder.cs + SceneSmoke.cs
|
||||
client/Assets/Vanguard/Scenes/*.unity
|
||||
|
|
@ -221,8 +238,10 @@ client/Assets/Plugins/Vanguard.Shared + MessagePack
|
|||
|
||||
## Следующие шаги
|
||||
|
||||
1. UX-план 3D-поля: Phase 2 — InputLock/AnimationConfig и контекстное меню;
|
||||
затем Power/Shield + стрелка, затем Guardian/Trigger/подписи.
|
||||
1. UX-план 3D-поля: Phase 2–4 закрыты (InputLock/AnimationConfig, контекстное
|
||||
меню, Power/Shield, стрелка, Guardian/Trigger, debug-подписи). Осталось:
|
||||
при необходимости вынести overlay-экраны в prefab-ассеты и отполировать
|
||||
расположение на реальном разрешении.
|
||||
2. Декбилдер: drag&drop, счётчики лимитов, live-валидация, пресеты.
|
||||
3. 3D-наполнение: модели карт/стола, prefab-экраны, анимации retire/ghost.
|
||||
4. Звук и настройки, DOTween.
|
||||
|
|
|
|||
Loading…
Reference in New Issue