diff --git a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs
index 0e202b5..baf5bfd 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Data/BoardTypes.cs
@@ -31,6 +31,7 @@ namespace Vanguard.Board3D
Deck,
Drop,
Hand,
+ Trigger,
Bind,
GenerationBreak,
}
@@ -156,6 +157,16 @@ namespace Vanguard.Board3D
card != null ? card.PrintingId : null,
faceUp);
+ public static BoardCardRef Trigger(int player, int index, CardView card) =>
+ new BoardCardRef(
+ BoardZone.Trigger,
+ player,
+ -6,
+ index,
+ card != null ? card.InstanceId : 0,
+ card != null ? card.PrintingId : null,
+ true);
+
public static BoardCardRef Pile(BoardZone zone, int player, int count) =>
new BoardCardRef(zone, player, -5, count, 0, null, false);
diff --git a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
index 67293e0..a420372 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/Logic/PlaymatLayout.cs
@@ -61,16 +61,14 @@ namespace Vanguard.Board3D
public Vector3 CirclePositionForPlayer(int player, int selfPlayer, int slot) =>
CirclePosition(ToLocalSeat(player, selfPlayer), slot);
- /// Круги урона: две колонки по три карты на боку.
+ /// Урон — один вертикальный ряд, карты на боку.
public Vector3 DamagePosition(int localSeat, int index)
{
var zSign = ZSign(localSeat);
var sideSign = localSeat == 0 ? -1f : 1f;
- var innerSign = localSeat == 0 ? -1f : 1f;
- var xCenter = sideSign * _config.damageX;
- var x = xCenter + (index % 2 == 0 ? innerSign : -innerSign) * 0.42f;
- var z = zSign * (_config.frontRowZ + 0.45f)
- + zSign * (index / 2) * 0.55f;
+ var x = sideSign * _config.damageX;
+ var z = zSign * (_config.frontRowZ + 0.4f)
+ + zSign * index * 0.4f;
return new Vector3(x, _config.DamageCenterY, z);
}
@@ -89,6 +87,16 @@ namespace Vanguard.Board3D
return new Vector3(x, _config.pileLift, z);
}
+ /// Зона проверки триггера — над колодой, ближе к центру стола.
+ public Vector3 TriggerPosition(int localSeat, int index, int count)
+ {
+ var x = (localSeat == 0 ? 1f : -1f) * _config.pileX;
+ var offset = index - (Mathf.Max(1, count) - 1) * 0.5f;
+ // «Над колодой» — дальше от камеры, то есть выше на экране.
+ var z = (ZSign(localSeat) * _config.frontRowZ) + 1.0f;
+ return new Vector3(x + offset * 0.42f, _config.cardLift, z);
+ }
+
/// Душа — визуальная стопка под кругом V, чуть ближе к игроку.
public Vector3 SoulPosition(int localSeat)
{
diff --git a/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs
index fe4b616..54eb2d1 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/PlaymatSandbox.cs
@@ -159,6 +159,7 @@ namespace Vanguard.Board3D
Card("BT01/011EN", 41),
Card("BT01/012EN", 42),
},
+ Trigger = new List { Card("BT01/090EN", 61) },
DeckCount = 38,
Drop = new List { Card("BT01/013EN", 51) },
};
@@ -177,6 +178,7 @@ namespace Vanguard.Board3D
Soul = new List { Card("BT01/021EN", 131) },
FaceDownDamage = 1,
FaceUpDamage = new List { Card("BT01/022EN", 141) },
+ Trigger = new List { Card("BT01/091EN", 161) },
DeckCount = 40,
Drop = new List(),
};
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs
index b7e8c1c..a950bc5 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/Card3D.cs
@@ -16,6 +16,7 @@ namespace Vanguard.Board3D
private MeshRenderer _highlight;
private BoxCollider _collider;
private MaterialPropertyBlock _block;
+ private TextMesh _stats;
private bool _faceUp = true;
private bool _rested;
@@ -36,6 +37,9 @@ namespace Vanguard.Board3D
public Vector3 TargetPosition => _targetPosition;
public float TargetYaw => _targetRotation.eulerAngles.y;
public Quaternion TargetRotation => _targetRotation;
+ public string StatsText => _stats != null && _stats.gameObject.activeSelf
+ ? _stats.text
+ : null;
public static Card3D Create(Transform parent, PlaymatLayoutConfig config)
{
@@ -95,6 +99,14 @@ namespace Vanguard.Board3D
BoardMaterials.SetShadows(_highlight, false);
_highlight.gameObject.SetActive(false);
+ _stats = BoardText.Create(
+ transform,
+ string.Empty,
+ 0.028f,
+ Color.white,
+ Vector3.zero);
+ _stats.gameObject.SetActive(false);
+
_collider = gameObject.AddComponent();
_collider.size = new Vector3(
width * 1.02f,
@@ -126,6 +138,37 @@ namespace Vanguard.Board3D
_front.SetPropertyBlock(_block);
}
+ public void SetStats(string text, bool modified)
+ {
+ if (_stats == null)
+ return;
+
+ if (string.IsNullOrEmpty(text))
+ {
+ _stats.gameObject.SetActive(false);
+ return;
+ }
+
+ _stats.text = text;
+ _stats.color = modified
+ ? new Color(1f, 0.82f, 0.28f)
+ : new Color(0.92f, 0.95f, 1f, 0.92f);
+ _stats.gameObject.SetActive(true);
+ }
+
+ private void LateUpdate()
+ {
+ if (_stats == null || !_stats.gameObject.activeSelf)
+ return;
+
+ var position = transform.position;
+ _stats.transform.position = new Vector3(
+ position.x,
+ position.y + 0.015f,
+ position.z - (_config.cardHeight * 0.62f));
+ _stats.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
+ }
+
public void SetState(
Quaternion baseRotation,
bool faceUp,
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
index 09e190c..efe16b7 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/GameBoard3D.cs
@@ -26,6 +26,7 @@ namespace Vanguard.Board3D
private readonly Stack _pool = new Stack();
private readonly List _piles = new List();
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
+ private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
private readonly HashSet _activeHighlights = new HashSet();
private readonly HashSet _validDropSlots = new HashSet();
private readonly List _handBindings = new List();
@@ -50,8 +51,6 @@ namespace Vanguard.Board3D
private Pile3D _deckOpponent;
private Pile3D _dropSelf;
private Pile3D _dropOpponent;
- private Pile3D _soulSelf;
- private Pile3D _soulOpponent;
private enum PointerMode
{
@@ -76,6 +75,7 @@ namespace Vanguard.Board3D
public BoardArtProvider ArtProvider { get; set; }
public event Action CardClicked;
+ public event Action CardRightClicked;
public event Action SlotClicked;
public event Action CardDropped;
public event Action PileClicked;
@@ -137,10 +137,8 @@ namespace Vanguard.Board3D
_deckSelf.SetPlayer(LocalToPlayer(0));
_dropSelf.SetPlayer(LocalToPlayer(0));
- _soulSelf.SetPlayer(LocalToPlayer(0));
_deckOpponent.SetPlayer(LocalToPlayer(1));
_dropOpponent.SetPlayer(LocalToPlayer(1));
- _soulOpponent.SetPlayer(LocalToPlayer(1));
_hasRendered = false;
}
@@ -208,6 +206,7 @@ namespace Vanguard.Board3D
spec.DamageLayout,
spec.Scale);
card.SetHighlight(spec.Highlight);
+ card.SetStats(spec.Stats, spec.StatsModified);
if (spec.IsHand)
{
@@ -356,9 +355,9 @@ namespace Vanguard.Board3D
new Vector3(
sideSign * (_config.damageX + 0.02f),
0.011f,
- zSign * (_config.frontRowZ + 1.15f)),
- 1.72f,
- 2.25f,
+ zSign * (_config.frontRowZ + 1.3f)),
+ 1.15f,
+ 2.6f,
Mathf.Max(0.018f, _config.cardWidth * 0.03f),
_config.damageColor,
null,
@@ -367,6 +366,21 @@ namespace Vanguard.Board3D
new Color(1f, 0.78f, 0.76f, 0.75f),
_config.highlightColor);
_damageZones[localSeat] = zone;
+
+ var triggerPosition = _layout.TriggerPosition(localSeat, 0, 1);
+ _triggerZones[localSeat] = BoardZoneView.Create(
+ _zonesRoot,
+ "Trigger Zone " + localSeat,
+ new Vector3(triggerPosition.x, 0.011f, triggerPosition.z),
+ 1.25f,
+ 1.05f,
+ Mathf.Max(0.016f, _config.cardWidth * 0.025f),
+ new Color(0.30f, 0.18f, 0.52f),
+ "TRIGGER",
+ 0.04f,
+ zSign * 0.72f,
+ new Color(0.82f, 0.76f, 1f, 0.75f),
+ _config.highlightColor);
}
}
@@ -379,8 +393,6 @@ namespace Vanguard.Board3D
_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));
- _soulSelf = CreatePile(BoardZone.Soul, 0, 0, "SOUL", _config.soulColor);
- _soulOpponent = CreatePile(BoardZone.Soul, 1, 1, "SOUL", _config.soulColor);
}
private Pile3D CreatePile(
@@ -452,6 +464,7 @@ namespace Vanguard.Board3D
AddFieldSpecs(player, localSeat, state, specs);
AddGuardianSpecs(view, player, localSeat, specs);
AddDamageSpecs(player, localSeat, state, specs);
+ AddTriggerSpecs(player, localSeat, specs);
}
AddHandSpecs(view, state, specs);
@@ -494,6 +507,9 @@ namespace Vanguard.Board3D
// Карты в кругах V/R всегда открыты, независимо от снимка.
FaceUp = true,
Rested = card.Rested,
+ Stats = "P" + card.Power + " C" + card.Critical,
+ StatsModified = card.Power != card.BasePower
+ || card.Critical != card.BaseCritical,
Highlight = state.Highlights.Contains(key)
? _config.highlightColor
: (Color?)null,
@@ -581,6 +597,33 @@ namespace Vanguard.Board3D
}
}
+ private void AddTriggerSpecs(
+ PlayerView player,
+ int localSeat,
+ List specs)
+ {
+ var triggers = player.Trigger ?? new List();
+ if (triggers.Count == 0)
+ return;
+
+ var count = Mathf.Min(3, triggers.Count);
+ for (var i = 0; i < count; i++)
+ {
+ var card = triggers[i];
+ specs.Add(new CardSpec
+ {
+ Key = "T:" + player.Index + ":" + card.InstanceId,
+ Ref = BoardCardRef.Trigger(player.Index, i, card),
+ Art = ResolveArt(card.PrintingId),
+ Position = _layout.TriggerPosition(localSeat, i, count),
+ // Карты проверки всегда развёрнуты к локальному игроку.
+ BaseRotation = Quaternion.identity,
+ FaceUp = true,
+ Rested = false,
+ });
+ }
+ }
+
private Color? DamageHighlight(BoardRenderState state, int index)
{
if (!state.DamageChoice && !state.AbilityDamageTargeting)
@@ -720,8 +763,8 @@ namespace Vanguard.Board3D
var self = view.Players.FirstOrDefault(player => player.Index == view.You);
var opponent = view.Players.FirstOrDefault(player => player.Index != view.You);
- UpdatePileSide(self, _deckSelf, _dropSelf, _soulSelf);
- UpdatePileSide(opponent, _deckOpponent, _dropOpponent, _soulOpponent);
+ UpdatePileSide(self, _deckSelf, _dropSelf);
+ UpdatePileSide(opponent, _deckOpponent, _dropOpponent);
_damageZones[0]?.SetLabel(string.Empty);
_damageZones[1]?.SetLabel(string.Empty);
@@ -733,8 +776,7 @@ namespace Vanguard.Board3D
private void UpdatePileSide(
PlayerView player,
Pile3D deck,
- Pile3D drop,
- Pile3D soul)
+ Pile3D drop)
{
if (deck == null)
return;
@@ -743,7 +785,6 @@ namespace Vanguard.Board3D
{
deck.SetCount(0, null, "DECK", Color.white);
drop.SetCount(0, null, "DROP", Color.white);
- soul.SetCount(0, null, "SOUL", _config.soulColor);
return;
}
@@ -761,15 +802,6 @@ namespace Vanguard.Board3D
topDrop != null ? ResolveArt(topDrop.PrintingId).Front : null,
"DROP",
new Color(0.30f, 0.30f, 0.40f));
-
- var topSoul = player.Soul != null && player.Soul.Count > 0
- ? player.Soul[player.Soul.Count - 1]
- : null;
- soul.SetCount(
- player.Soul?.Count ?? 0,
- topSoul != null ? ResolveArt(topSoul.PrintingId).Front : null,
- "SOUL",
- _config.soulColor);
}
private void UpdateSlots(BoardRenderState state)
@@ -827,10 +859,17 @@ namespace Vanguard.Board3D
}
var ray = GetRay(mouse.position.ReadValue());
- if (!TryRaycast(ray, out _, out _, out var pile) || pile == null)
+ if (!TryRaycast(ray, out var card, out _, out var pile))
return;
- PileRightClicked?.Invoke(pile.Ref);
+ if (card != null)
+ {
+ CardRightClicked?.Invoke(card.Ref);
+ return;
+ }
+
+ if (pile != null)
+ PileRightClicked?.Invoke(pile.Ref);
}
private void HandlePointer()
@@ -1249,6 +1288,8 @@ namespace Vanguard.Board3D
public bool IsHand;
public int HandIndex;
public int HandCount;
+ public string Stats;
+ public bool StatsModified;
public Color? Highlight;
}
}
diff --git a/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs b/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs
index 8d4f63f..09a0b4c 100644
--- a/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs
+++ b/client/Assets/Vanguard/Runtime/Board3D/View/Pile3D.cs
@@ -92,7 +92,18 @@ namespace Vanguard.Board3D
{
_count = count;
var safe = Mathf.Max(0, count);
- var layers = safe <= 0 ? 1 : safe < 5 ? 1 : safe < 20 ? 2 : 3;
+ if (safe == 0)
+ {
+ for (var i = 0; i < _layers.Length; i++)
+ _layers[i].SetActive(false);
+ _top.gameObject.SetActive(false);
+ if (_label != null)
+ BoardText.SetText(_label, title);
+ return;
+ }
+
+ _top.gameObject.SetActive(true);
+ var layers = safe < 5 ? 1 : safe < 20 ? 2 : 3;
if (Zone == BoardZone.Soul)
layers = safe < 10 ? 1 : 2;
var thickness = Mathf.Max(0.006f, _config.cardThickness * 1.2f);
diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs
index d50605a..e72d971 100644
--- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs
+++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs
@@ -80,6 +80,11 @@ namespace Vanguard.UI
private Text _soulTitle;
private Text _selfStatsText;
private Text _opponentStatsText;
+ private RectTransform _phasePanel;
+ private bool _autoFinishMulligan;
+ private float _autoFinishMulliganAt;
+ private bool _autoEndBattleArmed;
+ private float _autoEndBattleAt;
public BattleView(Transform parent, MetaService meta, Action onExit)
{
@@ -183,10 +188,12 @@ namespace Vanguard.UI
BuildSoulOverlay();
BuildStatsOverlays();
+ BuildPhasePanel();
_board3D = GameBoard3D.Create(null, PlaymatLayoutConfig.Default);
_board3D.ArtProvider = ResolveBoardArt;
_board3D.CardClicked += OnBoard3DCardClicked;
+ _board3D.CardRightClicked += OnBoard3DCardRightClicked;
_board3D.SlotClicked += OnBoard3DSlotClicked;
_board3D.CardDropped += OnBoard3DCardDropped;
_board3D.PileClicked += OnBoard3DPileClicked;
@@ -214,6 +221,10 @@ namespace Vanguard.UI
_pendingBoosterSlot = -1;
_triggerPowerSlot = null;
_showAllIntents = false;
+ _autoFinishMulligan = false;
+ _autoFinishMulliganAt = 0f;
+ _autoEndBattleArmed = false;
+ _autoEndBattleAt = 0f;
_logLines.Clear();
RebuildLog();
_feedbackQueue.Clear();
@@ -222,7 +233,6 @@ namespace Vanguard.UI
_feedback.gameObject.SetActive(false);
_flash.gameObject.SetActive(false);
_skipFeedback.gameObject.SetActive(false);
- _highlightSlots.Clear();
_board3D?.Clear();
_board3D?.SetVisible(true);
if (_soulOverlay != null)
@@ -272,6 +282,71 @@ namespace Vanguard.UI
UpdateClock();
UpdateFeedback();
+ UpdateAutoPhases();
+ }
+
+ private void UpdateAutoPhases()
+ {
+ if (_view == null || _sending)
+ return;
+
+ var legal = _view.LegalIntents ?? new List();
+
+ if (_autoFinishMulligan)
+ {
+ if (_view.Phase != GamePhase.Mulligan)
+ {
+ _autoFinishMulligan = false;
+ _autoFinishMulliganAt = 0f;
+ }
+ else if (_view.ActivePlayer == _view.You)
+ {
+ var finish = legal.OfType().FirstOrDefault();
+ if (finish != null)
+ {
+ if (_autoFinishMulliganAt <= 0f)
+ {
+ _autoFinishMulliganAt = Time.unscaledTime + 0.6f;
+ }
+ else if (Time.unscaledTime >= _autoFinishMulliganAt)
+ {
+ _autoFinishMulligan = false;
+ _autoFinishMulliganAt = 0f;
+ OnIntentClicked(finish);
+ }
+ }
+ }
+ }
+
+ var battle = _view.Phase == GamePhase.Battle
+ && _view.ActivePlayer == _view.You
+ && !_view.IsFinished;
+ var actionable = legal.Any(intent =>
+ intent is AttackIntent
+ or ActivateAbilityIntent
+ or ResolveAbilityIntent
+ or ResolveTriggerIntent
+ or SelectTriggerIntent
+ or ChooseIntent);
+ var endTurn = legal.OfType().FirstOrDefault();
+
+ if (battle && !actionable && endTurn != null && _pendingIntents.Count == 0)
+ {
+ if (!_autoEndBattleArmed)
+ {
+ _autoEndBattleArmed = true;
+ _autoEndBattleAt = Time.unscaledTime + 1.0f;
+ }
+ else if (Time.unscaledTime >= _autoEndBattleAt && !_feedbackPlaying)
+ {
+ _autoEndBattleArmed = false;
+ OnIntentClicked(endTurn);
+ }
+ }
+ else
+ {
+ _autoEndBattleArmed = false;
+ }
}
private void ShowFeedback(string text, Color color, float duration = 1.25f, bool flash = false)
@@ -529,40 +604,46 @@ namespace Vanguard.UI
case AttackDeclaredView attack:
ShowFeedback("АТАКА " + SlotName(attack.AttackerSlot)
+ " → " + SlotName(attack.TargetSlot),
- new Color(1f, 0.82f, 0.35f), 1.0f);
+ new Color(1f, 0.82f, 0.35f), 1.2f);
break;
case GuardAddedView guard:
- ShowFeedback("GUARD +" + guard.Shield,
- new Color(0.45f, 0.82f, 1f), 1.0f);
+ ShowFeedback("GUARD " + CardName(guard.PrintingId)
+ + " +" + guard.Shield,
+ new Color(0.45f, 0.82f, 1f), 1.9f);
break;
case DriveCheckedView drive:
- ShowFeedback("DRIVE " + TriggerShort(drive.Trigger.ToString()),
- new Color(0.72f, 0.88f, 1f), 1.0f);
+ ShowFeedback("DRIVE " + CardName(drive.PrintingId)
+ + " · " + TriggerShort(drive.Trigger.ToString()),
+ new Color(0.72f, 0.88f, 1f), 1.5f);
break;
case TriggerResolvedView trigger:
- ShowFeedback("TRIGGER " + trigger.Trigger,
- new Color(0.72f, 0.88f, 1f), 1.1f);
+ ShowFeedback("TRIGGER " + CardName(trigger.PrintingId),
+ new Color(0.72f, 0.88f, 1f), 1.4f);
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),
- 0.9f);
+ 1.3f);
break;
case DamageTakenView damage:
ShowFeedback("+" + damage.Amount + " DAMAGE",
- new Color(1f, 0.30f, 0.26f), 1.2f, flash: true);
+ new Color(1f, 0.30f, 0.26f), 1.6f, flash: true);
break;
case HealedView:
ShowFeedback("HEAL",
- new Color(0.45f, 0.95f, 0.62f), 1.1f, flash: true);
+ new Color(0.45f, 0.95f, 0.62f), 1.4f, flash: true);
break;
case UnitRetiredView:
ShowFeedback("RETIRE",
- new Color(1f, 0.55f, 0.30f), 1.0f);
+ new Color(1f, 0.55f, 0.30f), 1.2f);
break;
- case PerfectGuardView:
- ShowFeedback("PERFECT GUARD",
- new Color(0.60f, 0.82f, 1f), 1.1f);
+ case InterceptedView intercept:
+ ShowFeedback("INTERCEPT " + CardName(intercept.PrintingId),
+ new Color(0.55f, 0.82f, 1f), 1.9f);
+ break;
+ case PerfectGuardView perfect:
+ ShowFeedback("PERFECT GUARD " + CardName(perfect.SentinelPrintingId),
+ new Color(0.60f, 0.82f, 1f), 2.0f);
break;
}
}
@@ -672,6 +753,13 @@ namespace Vanguard.UI
_highlightSlots.Clear();
_pendingBoosterSlot = -1;
_triggerPowerSlot = null;
+ if (intent is FinishMulliganIntent or SkipRideIntent
+ or EnterBattleIntent or EndTurnIntent)
+ {
+ _autoFinishMulligan = false;
+ _autoFinishMulliganAt = 0f;
+ _autoEndBattleArmed = false;
+ }
_sending = true;
SetStatus("Отправляем: " + Label(intent), Muted);
@@ -773,6 +861,7 @@ namespace Vanguard.UI
UpdateStatsOverlay();
RebuildIntents(_view);
+ RebuildPhaseControls(_view);
UpdateClock();
if (_view.IsFinished)
@@ -997,6 +1086,15 @@ namespace Vanguard.UI
ShowSoul(pile.Player);
}
+ private void OnBoard3DCardRightClicked(BoardCardRef card)
+ {
+ if (_view == null)
+ return;
+
+ if (card.Zone == BoardZone.Vanguard)
+ ShowSoul(card.Player);
+ }
+
private void OnBoard3DPileClicked(BoardCardRef pile)
{
if (_view == null)
@@ -1022,9 +1120,80 @@ namespace Vanguard.UI
SetStatus("В колоде " + player.DeckCount + " карт.", Muted);
}
- private void BuildStatsOverlays()
+ private void BuildPhasePanel()
{
- _selfStatsText = CreateStatsPanel(
+ _phasePanel = UiKit.CreatePanel(
+ _root,
+ "PhasePanel",
+ new Color(0.02f, 0.035f, 0.055f, 0.84f));
+ _phasePanel.anchorMin = new Vector2(1f, 0.5f);
+ _phasePanel.anchorMax = new Vector2(1f, 0.5f);
+ _phasePanel.pivot = new Vector2(1f, 0.5f);
+ _phasePanel.anchoredPosition = new Vector2(-468f, 0f);
+ _phasePanel.sizeDelta = new Vector2(156f, 188f);
+ UiKit.AddVerticalLayout(_phasePanel, 8, 6);
+ _phasePanel.gameObject.SetActive(false);
+ }
+
+ private void RebuildPhaseControls(MatchView view)
+ {
+ if (_phasePanel == null)
+ return;
+
+ ClearChildren(_phasePanel);
+ if (view == null || _pendingIntents.Count > 0)
+ {
+ _phasePanel.gameObject.SetActive(false);
+ return;
+ }
+
+ var intents = (view.LegalIntents ?? new List())
+ .Where(IsPhaseIntent)
+ .ToList();
+ if (intents.Count == 0)
+ {
+ _phasePanel.gameObject.SetActive(false);
+ return;
+ }
+
+ _phasePanel.gameObject.SetActive(true);
+ var title = UiKit.CreateText(
+ _phasePanel,
+ "PhaseTitle",
+ "ФАЗЫ",
+ 14,
+ new Color(0.72f, 0.80f, 0.94f));
+ title.fontStyle = FontStyle.Bold;
+ UiKit.SetPreferredHeight(title, 22f);
+
+ foreach (var intent in intents)
+ {
+ var captured = intent;
+ var button = UiKit.CreateButton(
+ _phasePanel,
+ "Phase_" + captured.GetType().Name,
+ PhaseLabel(captured),
+ () => OnIntentClicked(captured),
+ 14);
+ UiKit.SetPreferredHeight(button, 40f);
+ }
+ }
+
+ private static bool IsPhaseIntent(IntentPayload intent) =>
+ intent is FinishMulliganIntent or SkipRideIntent
+ or EnterBattleIntent or EndTurnIntent;
+
+ private static string PhaseLabel(IntentPayload intent) => intent switch
+ {
+ FinishMulliganIntent => "Закончить муллиган",
+ SkipRideIntent => "Пропустить смену",
+ EnterBattleIntent => "В бой",
+ EndTurnIntent => "Закончить ход",
+ _ => intent.GetType().Name,
+ };
+
+ private void BuildStatsOverlays()
+ { _selfStatsText = CreateStatsPanel(
"SelfStats",
new Vector2(0f, 0f),
new Vector2(0f, 0f),
@@ -1316,6 +1485,8 @@ namespace Vanguard.UI
var indexes = _mulliganSelected.OrderBy(index => index).ToList();
_mulliganSelected.Clear();
_mulliganUsed = true;
+ _autoFinishMulligan = true;
+ _autoFinishMulliganAt = 0f;
OnIntentClicked(new MulliganIntent { HandIndexes = indexes });
}
@@ -1651,7 +1822,9 @@ namespace Vanguard.UI
var legal = view.LegalIntents ?? new List();
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
var board = playable.Where(IsBoardIntent).ToList();
- var global = playable.Where(intent => !IsBoardIntent(intent)).ToList();
+ var global = playable
+ .Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent))
+ .ToList();
if (_pendingIntents.Count > 0)
{
diff --git a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs
index 7206539..e532c56 100644
--- a/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs
+++ b/client/Assets/Vanguard/Tests/Editor/GameBoard3DTests.cs
@@ -42,6 +42,7 @@ namespace Vanguard.Tests
&& card.Ref.Slot == 3);
Assert.IsTrue(rested.IsRested);
Assert.AreEqual(90f, Mathf.Abs(Mathf.DeltaAngle(rested.TargetYaw, 0f)), 0.1f);
+ Assert.AreEqual("P8000 C1", rested.StatsText);
var hiddenDamage = cards.Single(card =>
card.Ref.Zone == BoardZone.Damage
diff --git a/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs b/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs
index c5fe03e..fae0845 100644
--- a/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs
+++ b/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs
@@ -72,6 +72,8 @@ namespace Vanguard.Tests
.ToList();
Assert.AreEqual(PlaymatLayout.DamageSlots,
damage.Select(position => position.ToString()).Distinct().Count());
+ Assert.IsTrue(damage.All(position =>
+ Mathf.Abs(position.x - damage[0].x) < 0.001f));
var hand = Enumerable.Range(0, 5)
.Select(index => layout.GetHandPose(index, 5).Position)
@@ -80,6 +82,18 @@ namespace Vanguard.Tests
Assert.IsTrue(hand.All(position => position.z < -4f));
}
+ [Test]
+ public void Trigger_Zone_Is_Above_Deck()
+ {
+ var config = PlaymatLayoutConfig.CreateDefault();
+ var layout = new PlaymatLayout(config);
+
+ var deck = layout.DeckPosition(0);
+ var trigger = layout.TriggerPosition(0, 0, 1);
+ Assert.Greater(trigger.z, deck.z);
+ Assert.AreEqual(deck.x, trigger.x, 0.0001f);
+ }
+
[Test]
public void Deck_Is_Closer_Than_Drop_After_Swap()
{
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index fa4b588..22728fa 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -866,18 +866,26 @@ Battle; Boot и общие сервисы остаются персистент
- Статистика карт вынесена в отдельные UI-панели: своя — снизу слева,
противника — сверху справа (`Рука/Колода/Дроп/Урон/Душа`); 3D-подписи
стопок и зон больше не дублируют числа.
-- Колода и дроп поменяны местами: колода ближе к игроку. Верх дропа
- показывает последнюю положенную карту, ЛКМ открывает список всего дропа.
- Душа открывается только ПКМ по стопке под V; кнопка души из топ-бара
- убрана.
-- Душа: клик по стопке или кнопка «Душа: n» в верхней панели открывает
- оверлей со списком карт.
+- Колода и дроп поменяны местами: колода ближе к игроку. Пустой дроп не
+ рисует рубашку; верх дропа показывает последнюю положенную карту, ЛКМ
+ открывает список всего дропа.
+- Душа — это юниты под картой VC, отдельной стопки на поле нет. ПКМ по
+ VC открывает список души; кнопка души из топ-бара убрана.
+- Над каждой колодой — триггер-зона (`TRIGGER`), куда выкладывается карта
+ проверки эффекта/урона. Урон — один вертикальный ряд карт на боку.
+ Под картами V/R — динамические `P/C`, жёлтые при изменённой силе/крите.
+- Справа от круга защиты — панель фаз: «Закончить муллиган» (авто после
+ обмена), «Пропустить смену», «В бой», «Закончить ход». Боевая фаза
+ завершается сама, если нет атак/активаций/выборов.
+- Event-фидбек guard/drive/intercept показывает имя карты и держится
+ 1.5–2 с; добавлены паузы, чтобы успеть прочитать защиту бота.
+- Атака проходит при силе атакующего **>=** силы защищающегося.
- Песочница: `Assets/Vanguard/Scenes/GameBoard3D.unity` +
`PlaymatSandbox`; собирается `Vanguard → Rebuild Playmat Sandbox`,
проверяется `SceneSmoke.RunPlaymat`.
-- Проверки: EditMode **19/19** (геометрия, зеркальность, правила, рендер
- 3D-карт, пул, рука в поле зрения, обмен колоды/дропа),
+- Проверки: EditMode **20/20** (геометрия, зеркальность, правила, рендер
+ 3D-карт, пул, рука в поле зрения, обмен колоды/дропа, триггер-зона),
`SCENE_SMOKE success=True`,
- `PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`.
+ `PLAYMAT_SMOKE success=True cards=22 hand=5/5 fieldVisible=True`.
- Ограничения: retire/ghost-анимации старого 2D-поля пока не перенесены;
модели карт/стола — процедурные примитивы.
diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md
index fd4c178..4f810aa 100644
--- a/docs/SESSION-STATE.md
+++ b/docs/SESSION-STATE.md
@@ -69,17 +69,23 @@
общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру
и всегда смотрит на игрока, а не лежит на столе; уменьшена, чтобы не
закрывать задний ряд RC. Камера фиксирована над полем под небольшим
- углом и не двигается игроком. Драг-н-дроп на плоскость стола с
- подсветкой целей и плавным снапом.
+ углом и не двигается игроком: `(1.4, 5.8, -8)`, цель `(1.4, 0, 0)`,
+ FOV 46. Драг-н-дроп на плоскость стола с подсветкой целей и плавным
+ снапом.
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
- противника зеркально; между полями общий круг защиты; душа — стопка под
- V; колода, дроп и урон — 3D-стопки/карты. Локальный игрок всегда снизу,
- даже если он за вторым местом. Карты в кругах V/R принудительно
- показываются лицом вверх.
+ противника зеркально; между полями общий круг защиты; колода, дроп,
+ триггер-зона и урон — 3D-зоны. Локальный игрок всегда снизу, даже если
+ он за вторым местом. Карты в кругах V/R принудительно показываются
+ лицом вверх; урон — один вертикальный ряд, карта входит открытой.
+ Над колодой — зона проверки триггера. Под картами V/R — динамические
+ `P/C`.
- Статистика карт — отдельное UI-наложение: своя панель снизу слева,
- противника — сверху справа. Душа открывается только ПКМ по стопке под V;
- дроп показывает верхнюю карту, по ЛКМ — список всех карт дропа; колода и
- дроп поменяны местами (колода ближе к игроку).
+ противника — сверху справа. Душа — юниты под VC: отдельной стопки нет,
+ ПКМ по VC открывает список. Дроп по умолчанию пуст; сверху показывает
+ последнюю карту, ЛКМ — список всего дропа. Колода и дроп поменяны
+ местами (колода ближе к игроку). Справа от круга защиты — панель фаз:
+ конец муллигана (авто после обмена), скип смены, конец главной/боевой;
+ боевая завершается сама, если нет атак и активаций.
- Зона урона: карты лежат открытыми и на боку; после Counter Blast
выбранная карта переворачивается рубашкой. Heal выбирает карту урона;
Counter Blast — открытую карту до активации способности.
@@ -87,8 +93,10 @@
колоды, добираются новые, затем shuffle. Клик по руке выбирает карты.
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
- 3D-карты плавно снапятся, поворачиваются в Rest и переворачиваются;
- подсветка целей и точки броска обновляются на каждом снимке.
+ Guard/drive/intercept показывают имя карты и держатся 1.5–2 с, чтобы
+ успеть прочитать защиту бота. 3D-карты плавно снапятся, поворачиваются
+ в Rest и переворачиваются; подсветка целей и точки броска обновляются
+ на каждом снимке.
- Реальные арты: `imageKey` из `/api/cards` + `CardImageService` ищет
`Resources/CardArt/{imageKey}`. Скачаны все 124 арта. Импорт:
`tools/import_card_art.py`.
@@ -96,6 +104,7 @@
группировка действий (карты → цели, глобальные кнопки отдельно),
выбор силы/эффекта триггера, журнал последних событий. Позванные
rear-guard'ы входят в стоячем положении; rest — только после атаки/буста.
+ Атака проходит при силе атакующего **>=** силы защищающегося.
В первый ход атака запрещена; первый игрок определяется сидом.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
@@ -139,12 +148,12 @@ curl http://127.0.0.1:18080/health
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
```
-Клиентские проверки: **19/19** EditMode (сборка всех экранов, импорт арта,
+Клиентские проверки: **20/20** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
-перемещения, рука в поле зрения камеры, обмен колоды/дропа),
+перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона),
`SCENE_SMOKE success=True`,
-`PLAYMAT_SMOKE success=True cards=20 hand=5/5 fieldVisible=True`.
+`PLAYMAT_SMOKE success=True cards=22 hand=5/5 fieldVisible=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:
diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs
index ca6329b..bcfd942 100644
--- a/server/src/Vanguard.Rules/Engine/GameEngine.cs
+++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs
@@ -2533,7 +2533,7 @@ public sealed class GameEngine
var attackPower = EffectivePower(attacker);
var defensePower = EffectivePower(defenseUnit) + battle.GuardShield;
- var hit = !battle.Nullified && attackPower > defensePower;
+ var hit = !battle.Nullified && attackPower >= defensePower;
_events.Add(new BattleResolvedEvent(hit));
diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
index 392b2e7..982b6a0 100644
--- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
+++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs
@@ -216,6 +216,43 @@ public class GameEngineTests
Assert.Single(engine.State.Players[1].Drop);
}
+ [Fact]
+ public void Equal_Power_Attack_Hits()
+ {
+ var engine = ReadyToAttack();
+ var attacker = engine.State.Players[0].Vanguard!;
+ var defender = engine.State.Players[1].Vanguard!;
+ var attackPower = engine.EffectivePower(attacker);
+ var defensePower = engine.EffectivePower(defender);
+
+ if (attackPower > defensePower)
+ {
+ defender.Modifiers.Add(new StatModifier
+ {
+ Power = attackPower - defensePower,
+ Source = defender,
+ EffectId = "equalize",
+ Duration = ModifierDuration.UntilEndOfBattle,
+ });
+ }
+ else if (defensePower > attackPower)
+ {
+ attacker.Modifiers.Add(new StatModifier
+ {
+ Power = defensePower - attackPower,
+ Source = attacker,
+ EffectId = "equalize",
+ Duration = ModifierDuration.UntilEndOfBattle,
+ });
+ }
+
+ Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
+ Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
+
+ Assert.Single(engine.State.Players[1].Damage);
+ Assert.Contains(engine.Events, e => e is BattleResolvedEvent { Hit: true });
+ }
+
[Fact]
public void Sixth_Damage_Ends_The_Game()
{