Add phase controls, trigger zone, card stats and combat fixes
CI / test (push) Has been cancelled
Details
CI / test (push) Has been cancelled
Details
This commit is contained in:
parent
e3243e58b6
commit
fdf88894e7
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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-поля пока не перенесены;
|
||||
модели карт/стола — процедурные примитивы.
|
||||
|
|
|
|||
|
|
@ -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` пересобирает сцены:
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue