Add phase controls, trigger zone, card stats and combat fixes
CI / test (push) Has been cancelled Details

This commit is contained in:
MpakobecEx 2026-10-22 20:48:26 +05:00
parent e3243e58b6
commit fdf88894e7
13 changed files with 432 additions and 74 deletions

View File

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

View File

@ -61,16 +61,14 @@ namespace Vanguard.Board3D
public Vector3 CirclePositionForPlayer(int player, int selfPlayer, int slot) =>
CirclePosition(ToLocalSeat(player, selfPlayer), slot);
/// <summary>Круги урона: две колонки по три карты на боку.</summary>
/// <summary>Урон — один вертикальный ряд, карты на боку.</summary>
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);
}
/// <summary>Зона проверки триггера — над колодой, ближе к центру стола.</summary>
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);
}
/// <summary>Душа — визуальная стопка под кругом V, чуть ближе к игроку.</summary>
public Vector3 SoulPosition(int localSeat)
{

View File

@ -159,6 +159,7 @@ namespace Vanguard.Board3D
Card("BT01/011EN", 41),
Card("BT01/012EN", 42),
},
Trigger = new List<CardView> { Card("BT01/090EN", 61) },
DeckCount = 38,
Drop = new List<CardView> { Card("BT01/013EN", 51) },
};
@ -177,6 +178,7 @@ namespace Vanguard.Board3D
Soul = new List<CardView> { Card("BT01/021EN", 131) },
FaceDownDamage = 1,
FaceUpDamage = new List<CardView> { Card("BT01/022EN", 141) },
Trigger = new List<CardView> { Card("BT01/091EN", 161) },
DeckCount = 40,
Drop = new List<CardView>(),
};

View File

@ -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<BoxCollider>();
_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,

View File

@ -26,6 +26,7 @@ namespace Vanguard.Board3D
private readonly Stack<Card3D> _pool = new Stack<Card3D>();
private readonly List<Pile3D> _piles = new List<Pile3D>();
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
private readonly HashSet<BoardSlotKey> _activeHighlights = new HashSet<BoardSlotKey>();
private readonly HashSet<BoardSlotKey> _validDropSlots = new HashSet<BoardSlotKey>();
private readonly List<HandCardBinding> _handBindings = new List<HandCardBinding>();
@ -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<BoardCardRef> CardClicked;
public event Action<BoardCardRef> CardRightClicked;
public event Action<BoardCardRef> SlotClicked;
public event Action<BoardCardRef, BoardCardRef> CardDropped;
public event Action<BoardCardRef> 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<CardSpec> specs)
{
var triggers = player.Trigger ?? new List<CardView>();
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;
}
}

View File

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

View File

@ -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<IntentPayload>();
if (_autoFinishMulligan)
{
if (_view.Phase != GamePhase.Mulligan)
{
_autoFinishMulligan = false;
_autoFinishMulliganAt = 0f;
}
else if (_view.ActivePlayer == _view.You)
{
var finish = legal.OfType<FinishMulliganIntent>().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<EndTurnIntent>().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<IntentPayload>())
.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<IntentPayload>();
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)
{

View File

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

View File

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

View File

@ -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-поля пока не перенесены;
модели карт/стола — процедурные примитивы.

View File

@ -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` пересобирает сцены:

View File

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

View File

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