Add input lock, card actions, combat stats and attack arrow
CI / test (push) Successful in 2m27s Details

This commit is contained in:
MpakobecEx 2026-10-23 15:13:04 +05:00
parent 309b56e4d7
commit 50487ed3f0
23 changed files with 1358 additions and 64 deletions

View File

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

View File

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

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 233e60b202fccbf4d868f32ee2a54777
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0636c0f7bf02c0548adae5e43ddb8f26

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 74c6d538edc00d040ac6486bfa5513ce

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 11b04f902000e5049a3dbc457cd1f570

View File

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

View File

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

View File

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

View File

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

View File

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