From fdf88894e7963bd2188b6e54f3e1b62d5458dbf7 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Thu, 22 Oct 2026 20:48:26 +0500 Subject: [PATCH] Add phase controls, trigger zone, card stats and combat fixes --- .../Runtime/Board3D/Data/BoardTypes.cs | 11 + .../Runtime/Board3D/Logic/PlaymatLayout.cs | 20 +- .../Runtime/Board3D/PlaymatSandbox.cs | 2 + .../Vanguard/Runtime/Board3D/View/Card3D.cs | 43 ++++ .../Runtime/Board3D/View/GameBoard3D.cs | 91 +++++--- .../Vanguard/Runtime/Board3D/View/Pile3D.cs | 13 +- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 209 ++++++++++++++++-- .../Vanguard/Tests/Editor/GameBoard3DTests.cs | 1 + .../Tests/Editor/PlaymatLayoutTests.cs | 14 ++ docs/HANDOFF.md | 26 ++- docs/SESSION-STATE.md | 37 ++-- .../src/Vanguard.Rules/Engine/GameEngine.cs | 2 +- .../Vanguard.Rules.Tests/GameEngineTests.cs | 37 ++++ 13 files changed, 432 insertions(+), 74 deletions(-) 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() {