Improve battle cards, mulligan and damage zone
CI / test (push) Failing after 4m25s Details

This commit is contained in:
MpakobecEx 2026-10-22 01:40:29 +05:00
parent 0e2907803a
commit f5b7f3e2d8
5 changed files with 399 additions and 79 deletions

View File

@ -71,6 +71,7 @@ namespace Vanguard.UI
private readonly HashSet<string> _highlightKeys = new HashSet<string>(StringComparer.Ordinal);
private int _pendingBoosterSlot = -1;
private int? _triggerPowerSlot;
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
private readonly Dictionary<string, RectTransform> _cardPanels =
new Dictionary<string, RectTransform>(StringComparer.Ordinal);
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
@ -205,6 +206,7 @@ namespace Vanguard.UI
_result.gameObject.SetActive(false);
_pendingIntents.Clear();
_highlightKeys.Clear();
_mulliganSelected.Clear();
_pendingBoosterSlot = -1;
_triggerPowerSlot = null;
_showAllIntents = false;
@ -1181,16 +1183,123 @@ namespace Vanguard.UI
{
var column = UiKit.CreatePanel(parent, "DamageColumn", new Color(0f, 0f, 0f, 0f));
var element = column.gameObject.AddComponent<LayoutElement>();
element.preferredWidth = 88f;
element.minWidth = 80f;
element.preferredWidth = 174f;
element.minWidth = 158f;
element.flexibleWidth = 0f;
UiKit.AddVerticalLayout(column, 0, 5);
UiKit.AddVerticalLayout(column, 0, 4);
AddPile(column, "УРОН",
player.FaceDownDamage + player.FaceUpDamage.Count,
new Color(0.48f, 0.12f, 0.12f), 118f);
AddDamageZone(column, player);
AddPile(column, "SOUL", player.Soul.Count,
new Color(0.26f, 0.18f, 0.42f), 96f);
new Color(0.26f, 0.18f, 0.42f), 78f);
}
private void AddDamageZone(Transform parent, PlayerView player)
{
var count = player.FaceDownDamage + player.FaceUpDamage.Count;
var zone = UiKit.CreatePanel(
parent, "DamageZone", new Color(0.16f, 0.05f, 0.06f, 0.9f));
var element = zone.gameObject.AddComponent<LayoutElement>();
element.preferredHeight = 206f;
element.flexibleWidth = 1f;
UiKit.AddVerticalLayout(zone, 4, 2);
var title = UiKit.CreateText(
zone, "Title", "УРОН · " + count, 11,
new Color(1f, 0.78f, 0.72f), TextAnchor.MiddleCenter);
title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 18f);
var grid = UiKit.CreatePanel(zone, "DamageCards", new Color(0f, 0f, 0f, 0f));
var gridElement = grid.gameObject.AddComponent<LayoutElement>();
gridElement.flexibleHeight = 1f;
gridElement.preferredHeight = 176f;
var damageChoice = IsDamageChoice();
var displayIndex = 0;
for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++)
{
AddDamageCard(grid, displayIndex, null, false, damageChoice);
}
foreach (var card in player.FaceUpDamage)
{
AddDamageCard(grid, displayIndex, card, true, damageChoice);
displayIndex++;
}
}
private void AddDamageCard(
RectTransform parent,
int displayIndex,
CardView card,
bool faceUp,
bool damageChoice)
{
var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white);
var rect = panel;
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(58f, 78f);
rect.anchoredPosition = new Vector2(
displayIndex % 2 == 0 ? -42f : 42f,
58f - (displayIndex / 2) * 58f);
rect.localRotation = Quaternion.Euler(0f, 0f, -90f);
var image = panel.GetComponent<Image>();
image.raycastTarget = true;
var meta = card != null && _catalog.TryGetValue(card.PrintingId, out var found)
? found
: null;
if (faceUp)
{
CardImageService.Apply(
image,
meta != null ? meta.ImageKey : null,
CardArt.ForCard(
meta != null ? meta.NameId : card?.PrintingId ?? "damage",
meta != null ? meta.Clan : "None"));
}
else
{
image.sprite = CardArt.Back;
image.color = Color.white;
}
var outline = panel.gameObject.AddComponent<Outline>();
outline.effectColor = damageChoice
? new Color(0.98f, 0.82f, 0.42f)
: new Color(0.42f, 0.18f, 0.18f);
outline.effectDistance = damageChoice
? new Vector2(3f, -3f)
: new Vector2(1f, -1f);
var button = panel.gameObject.AddComponent<Button>();
button.targetGraphic = image;
var capturedIndex = displayIndex;
button.onClick.AddListener(() => OnDamageClicked(capturedIndex));
button.interactable = damageChoice;
}
private bool IsDamageChoice() =>
_view?.Choice != null
&& _view.Choice.Prompt != null
&& _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0;
private void OnDamageClicked(int displayIndex)
{
if (_view?.Choice?.Options == null || !IsDamageChoice())
return;
for (var i = 0; i < _view.Choice.Options.Count; i++)
{
if (_view.Choice.Options[i].Slot == displayIndex)
{
OnIntentClicked(new ChooseIntent { Index = i });
return;
}
}
}
private void AddPileColumn(Transform parent, PlayerView player)
@ -1335,6 +1444,14 @@ namespace Vanguard.UI
var highlighted = _highlightKeys.Contains(
TargetKey(playerIndex, slotIndex, card != null ? card.PrintingId : null));
if (!highlighted
&& card != null
&& slotIndex == -2
&& playerIndex == _view.You
&& IsMulliganSelecting())
{
highlighted = _mulliganSelected.Contains(HandIndexOf(card.InstanceId));
}
var button = panel.gameObject.AddComponent<Button>();
button.targetGraphic = panel.GetComponent<Image>();
button.onClick.AddListener(() => OnBoardTargetClicked(new BoardTarget(
@ -1364,13 +1481,6 @@ namespace Vanguard.UI
: new Vector2(0f, 250f);
AnimateCardIn(panel, offset);
}
UiKit.AddVerticalLayout(panel, 4, 2);
var slotText = UiKit.CreateText(
panel, "Slot", slot, 10,
new Color(0.58f, 0.66f, 0.80f), TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(slotText, 14f);
if (card == null)
{
var panelImage = panel.GetComponent<Image>();
@ -1381,13 +1491,16 @@ namespace Vanguard.UI
var empty = UiKit.CreateText(
panel, "EmptyLabel", "Пусто", 12,
new Color(1f, 1f, 1f, 0.40f), TextAnchor.MiddleCenter);
UiKit.SetPreferredHeight(empty, compact ? 100f : 116f);
UiKit.Stretch(empty.rectTransform);
empty.rectTransform.offsetMin = new Vector2(4f, 4f);
empty.rectTransform.offsetMax = new Vector2(-4f, -4f);
return;
}
var art = UiKit.CreatePanel(panel, "Art", Color.white);
UiKit.SetPreferredHeight(art, compact ? 46f : 58f);
UiKit.Stretch(art);
var artImage = art.GetComponent<Image>();
artImage.raycastTarget = false;
if (card.FaceUp)
{
CardImageService.Apply(
@ -1404,60 +1517,65 @@ namespace Vanguard.UI
artImage.preserveAspect = false;
}
if (card.FaceUp)
{
var artName = UiKit.CreateText(
art, "ArtName", meta != null ? Loc.CardName(meta) : card.PrintingId, 10,
new Color(1f, 1f, 1f, 0.94f), TextAnchor.MiddleCenter);
UiKit.MakeLiteral(artName);
UiKit.Stretch(artName.rectTransform);
artName.rectTransform.offsetMin = new Vector2(3f, 3f);
artName.rectTransform.offsetMax = new Vector2(-3f, -2f);
artName.horizontalOverflow = HorizontalWrapMode.Wrap;
var slotText = UiKit.CreateText(
panel, "Slot", slot, 9,
new Color(0.92f, 0.95f, 1f), TextAnchor.UpperLeft);
var slotRect = slotText.rectTransform;
slotRect.anchorMin = new Vector2(0f, 1f);
slotRect.anchorMax = new Vector2(0f, 1f);
slotRect.pivot = new Vector2(0f, 1f);
slotRect.anchoredPosition = new Vector2(4f, -3f);
slotRect.sizeDelta = new Vector2(42f, 14f);
slotText.raycastTarget = false;
if (meta != null)
{
var grade = UiKit.CreateText(
art, "Grade", "G" + meta.Grade, 10,
Color.white, TextAnchor.MiddleCenter);
var gradeRect = grade.rectTransform;
gradeRect.anchorMin = new Vector2(1f, 1f);
gradeRect.anchorMax = new Vector2(1f, 1f);
gradeRect.pivot = new Vector2(1f, 1f);
gradeRect.anchoredPosition = new Vector2(-3f, -3f);
gradeRect.sizeDelta = new Vector2(25f, 16f);
}
var shade = UiKit.CreatePanel(panel, "Shade", new Color(0f, 0f, 0f, 0.72f));
var shadeRect = shade;
shadeRect.anchorMin = new Vector2(0f, 0f);
shadeRect.anchorMax = new Vector2(1f, 0f);
shadeRect.pivot = new Vector2(0.5f, 0f);
shadeRect.anchoredPosition = Vector2.zero;
shadeRect.sizeDelta = new Vector2(0f, compact ? 40f : 44f);
shade.GetComponent<Image>().raycastTarget = false;
if (card.FaceUp && meta != null)
{
var grade = UiKit.CreateText(
panel, "Grade", "G" + meta.Grade, 9,
Color.white, TextAnchor.UpperRight);
var gradeRect = grade.rectTransform;
gradeRect.anchorMin = new Vector2(1f, 1f);
gradeRect.anchorMax = new Vector2(1f, 1f);
gradeRect.pivot = new Vector2(1f, 1f);
gradeRect.anchoredPosition = new Vector2(-4f, -3f);
gradeRect.sizeDelta = new Vector2(28f, 14f);
grade.raycastTarget = false;
}
else
if (!card.FaceUp)
{
var hidden = UiKit.CreateText(
art, "Hidden", "?", 26, new Color(1f, 1f, 1f, 0.8f));
panel, "Hidden", "?", 26, new Color(1f, 1f, 1f, 0.8f));
UiKit.Stretch(hidden.rectTransform);
}
if (card.Rested)
{
var rested = UiKit.CreateText(
art, "Rested", "REST", 10,
new Color(1f, 0.55f, 0.35f), TextAnchor.MiddleCenter);
var restedRect = rested.rectTransform;
restedRect.anchorMin = new Vector2(0f, 0f);
restedRect.anchorMax = new Vector2(0f, 0f);
restedRect.pivot = new Vector2(0f, 0f);
restedRect.anchoredPosition = new Vector2(3f, 3f);
restedRect.sizeDelta = new Vector2(34f, 16f);
hidden.raycastTarget = false;
}
var name = UiKit.CreateText(
panel, "Name",
meta != null ? Loc.CardName(meta) : card.PrintingId,
10,
compact ? 9 : 10,
card.FaceUp ? Color.white : Muted,
TextAnchor.UpperLeft);
TextAnchor.LowerLeft);
UiKit.MakeLiteral(name);
var nameRect = name.rectTransform;
nameRect.anchorMin = new Vector2(0f, 0f);
nameRect.anchorMax = new Vector2(1f, 0f);
nameRect.pivot = new Vector2(0.5f, 0f);
nameRect.anchoredPosition = new Vector2(0f, compact ? 18f : 20f);
nameRect.sizeDelta = new Vector2(-8f, 20f);
name.horizontalOverflow = HorizontalWrapMode.Wrap;
name.verticalOverflow = VerticalWrapMode.Truncate;
name.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(name, compact ? 22f : 24f);
name.raycastTarget = false;
var stats = UiKit.CreateText(
panel, "Stats",
@ -1465,12 +1583,21 @@ namespace Vanguard.UI
? "P " + card.Power + " · C " + card.Critical
+ (meta != null ? " · " + TriggerShort(meta.Trigger) : string.Empty)
: "СКРЫТА",
10,
compact ? 8 : 9,
card.FaceUp && card.Power != card.BasePower
? new Color(1f, 0.82f, 0.42f)
: new Color(0.72f, 0.78f, 0.92f),
TextAnchor.MiddleLeft);
UiKit.SetPreferredHeight(stats, 16f);
TextAnchor.LowerLeft);
var statsRect = stats.rectTransform;
statsRect.anchorMin = new Vector2(0f, 0f);
statsRect.anchorMax = new Vector2(1f, 0f);
statsRect.pivot = new Vector2(0.5f, 0f);
statsRect.anchoredPosition = new Vector2(0f, 2f);
statsRect.sizeDelta = new Vector2(-8f, 14f);
stats.raycastTarget = false;
if (card.Rested)
panel.localRotation = Quaternion.Euler(0f, 0f, -90f);
}
private static string TriggerShort(string trigger) => trigger switch
@ -1485,11 +1612,65 @@ namespace Vanguard.UI
private string CardName(string printingId) =>
_catalog.TryGetValue(printingId, out var meta) ? Loc.CardName(meta) : printingId;
private bool IsMulliganSelecting() =>
_view != null
&& !_view.IsFinished
&& _view.Phase == GamePhase.Mulligan
&& _view.ActivePlayer == _view.You;
private int HandIndexOf(int instanceId)
{
var hand = _view?.Players.FirstOrDefault(player => player.Index == _view.You)?.Hand;
if (hand == null)
return -1;
for (var i = 0; i < hand.Count; i++)
{
if (hand[i].InstanceId == instanceId)
return i;
}
return -1;
}
private void ToggleMulligan(int index)
{
if (!_mulliganSelected.Add(index))
_mulliganSelected.Remove(index);
SetStatus(
_mulliganSelected.Count == 0
? "Кликните карты, которые вернутся в колоду."
: "К обмену выбрано карт: " + _mulliganSelected.Count + ".",
new Color(0.98f, 0.82f, 0.42f));
Render();
}
private void SendMulligan()
{
if (!IsMulliganSelecting() || _mulliganSelected.Count == 0)
return;
var indexes = _mulliganSelected.OrderBy(index => index).ToList();
_mulliganSelected.Clear();
OnIntentClicked(new MulliganIntent { HandIndexes = indexes });
}
private void OnBoardTargetClicked(BoardTarget target)
{
if (_view == null || _sending)
return;
if (IsMulliganSelecting() && target.IsHand && target.Player == _view.You)
{
var handIndex = HandIndexOf(target.InstanceId);
if (handIndex >= 0)
{
ToggleMulligan(handIndex);
return;
}
}
var legal = _view.LegalIntents ?? new List<IntentPayload>();
if (_pendingIntents.Any(intent => intent is ResolveTriggerIntent)
@ -1849,6 +2030,30 @@ namespace Vanguard.UI
return;
}
if (IsMulliganSelecting())
{
var mulliganHint = UiKit.CreateText(
_intents,
"MulliganHint",
"Кликните карты, которые вернутся в колоду.",
15,
new Color(0.72f, 0.80f, 0.94f),
TextAnchor.UpperLeft);
mulliganHint.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(mulliganHint, 34f);
if (_mulliganSelected.Count > 0)
{
var exchange = UiKit.CreateButton(
_intents,
"MulliganExchange",
"Маллиган: " + _mulliganSelected.Count + " карт",
SendMulligan,
16);
UiKit.SetPreferredHeight(exchange, 44f);
}
}
if (board.Count > 0)
{
var hint = UiKit.CreateText(

View File

@ -62,6 +62,12 @@ namespace Vanguard.Tests
Resources.Load<Texture2D>("CardArt/td01_005"),
"TD01 card art is not imported");
var artGo = new GameObject("ArtProbe", typeof(RectTransform), typeof(Image));
var artImage = artGo.GetComponent<Image>();
CardImageService.Apply(artImage, "td01_005", null);
Assert.IsNotNull(artImage.sprite, "CardImageService did not load real card art");
Object.DestroyImmediate(artGo);
// После смены сцены старые views не должны реагировать на PlayerState.
lobby.Dispose();
store.Dispose();

View File

@ -573,7 +573,7 @@ public sealed class GameEngine
return false;
p.Soul.Remove(card);
card.Rested = true;
card.Rested = false;
p.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
@ -1131,7 +1131,7 @@ public sealed class GameEngine
return;
}
card.Rested = true;
card.Rested = false;
p.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(player, slot, card.PrintingId));
@ -1794,7 +1794,7 @@ public sealed class GameEngine
var card = player.Hand[index];
player.Hand.RemoveAt(index);
card.Rested = true;
card.Rested = false;
player.RearGuards[slot] = card;
_events.Add(new UnitCalledEvent(State.ActivePlayer, slot, card.PrintingId));
@ -2326,10 +2326,17 @@ public sealed class GameEngine
Draw(player, 1);
break;
case TriggerType.Heal:
Heal(player);
if (TryBeginHeal(player, pending, command))
return CommandResult.Ok();
break;
}
return CompleteTrigger(pending, command);
}
private CommandResult CompleteTrigger(PendingTrigger pending, ResolveTriggerCommand command)
{
var player = pending.Player;
State.PendingTrigger = null;
_events.Add(new TriggerAppliedEvent(player, command.PowerTarget, command.EffectTarget));
@ -2353,6 +2360,65 @@ public sealed class GameEngine
return CommandResult.Ok();
}
private bool TryBeginHeal(int player, PendingTrigger pending, ResolveTriggerCommand command)
{
var p = State.Players[player];
var opponent = State.Players[1 - player];
if (p.Damage.Count <= opponent.Damage.Count || p.Damage.Count == 0)
return false;
AskDamageChoice(
player,
"Выберите карту урона для лечения",
actualIndex =>
{
var card = p.Damage[actualIndex];
p.Damage.RemoveAt(actualIndex);
p.Drop.Add(card);
_events.Add(new HealedEvent(player));
});
var previousContinuation = State.Continuation;
State.Continuation = () =>
{
State.Continuation = previousContinuation;
CompleteTrigger(pending, command);
};
return true;
}
private void AskDamageChoice(int player, string prompt, Action<int> onChosen)
{
var damage = State.Players[player].Damage;
// Закрытый урон показываем первым, затем открытый: так клиент
// отрисовывает зону урона и может сопоставить клик с вариантом.
var order = damage
.Select((card, index) => (card, index))
.OrderBy(item => item.card.FaceUp)
.ThenBy(item => item.index)
.ToList();
var options = order
.Select((item, displayIndex) => new ChoiceOption(
displayIndex,
item.card.FaceUp
? State.Catalog.CardOf(item.card.PrintingId).NameEn
: "Закрытая карта"))
.ToList();
State.PendingChoice = new PendingChoice
{
Player = player,
Prompt = prompt,
Options = options,
Kind = ChoiceTargetKind.Damage,
Resolve = displayIndex => onChosen(order[displayIndex].index),
};
State.Step = Step.Choice;
State.ActivePlayer = player;
}
private void FinishBattle(BattleState battle)
{
if (State.IsFinished)
@ -2609,20 +2675,6 @@ public sealed class GameEngine
ClearBattle(battle);
}
private void Heal(int player)
{
var p = State.Players[player];
var opponent = State.Players[1 - player];
if (p.Damage.Count <= opponent.Damage.Count || p.Damage.Count == 0)
return;
var card = p.Damage[^1];
p.Damage.RemoveAt(p.Damage.Count - 1);
p.Drop.Add(card);
_events.Add(new HealedEvent(player));
}
// ---------- служебное ----------
private CardInstance? TakeTop(int player)

View File

@ -33,12 +33,23 @@ public sealed record UnitTarget(int Player, int Slot);
/// Ожидание выбора игрока. Resolve получает индекс выбранного варианта.
/// Пока выбор не сделан, движок не продолжает разрешение способности.
/// </summary>
public enum ChoiceTargetKind
{
None,
Unit,
Hand,
Damage,
Deck,
Other,
}
public sealed class PendingChoice
{
public required int Player { get; init; }
public required string Prompt { get; init; }
public required IReadOnlyList<ChoiceOption> Options { get; init; }
public required Action<int> Resolve { get; init; }
public ChoiceTargetKind Kind { get; init; }
}
public enum TriggerContext

View File

@ -18,6 +18,7 @@ public class GameEngineTests
private const string Nike = "BT01/056EN"; // Critical
private const string Flogal = "BT01/046EN"; // Stand
private const string Govannon = "BT01/045EN"; // Draw
private const string Elaine = "BT01/047EN"; // Heal
private const string Iseult = "BT01/011EN"; // Sentinel, Royal Paladin
private const string Nehalem = "BT01/022EN"; // Kagero
private const string Overlord = "BT01/004EN"; // Kagero, 11000
@ -99,13 +100,29 @@ public class GameEngineTests
}
[Fact]
public void Called_RearGuard_Enters_Rested_And_Slot_Is_Busy()
public void Mulligan_Replaces_Selected_Hand_Cards_And_Keeps_Five()
{
var engine = NewEngine();
engine.Start();
Assert.Equal(Phase.Mulligan, engine.State.Phase);
Assert.Equal(5, engine.State.Players[0].Hand.Count);
Assert.True(engine.Execute(new MulliganCommand(new[] { 0, 2 })).Accepted);
Assert.Equal(5, engine.State.Players[0].Hand.Count);
Assert.Equal(44, engine.State.Players[0].Deck.Count);
Assert.Contains(engine.Events, e => e is MulliganEvent { Player: 0, Count: 2 });
}
[Fact]
public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy()
{
var engine = InMainPhase();
Give(engine, 0, Marron);
Assert.True(engine.Execute(new CallCommand(Marron, 0)).Accepted);
Assert.True(engine.State.Players[0].RearGuards[0]!.Rested);
Assert.False(engine.State.Players[0].RearGuards[0]!.Rested);
Give(engine, 0, Marron);
var second = engine.Execute(new CallCommand(Marron, 0));
@ -222,6 +239,35 @@ public class GameEngineTests
Assert.Contains(engine.Events, e => e is TriggerAppliedEvent { Player: 1 });
}
[Fact]
public void Heal_Trigger_Asks_For_Damage_Card_And_Heals_Selected_One()
{
var engine = ReadyToAttack();
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
GiveDamage(engine, 1, 2);
GiveDamage(engine, 0, 1);
StackDeck(engine.State.Players[1], Elaine);
var chosen = engine.State.Players[1].Damage[0];
Assert.True(engine.Execute(new AttackCommand(-1, -1)).Accepted);
Assert.True(engine.Execute(new PassGuardCommand()).Accepted);
Assert.Equal(TriggerType.Heal, engine.State.PendingTrigger!.Trigger);
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, -1)).Accepted);
Assert.NotNull(engine.State.PendingChoice);
Assert.Equal(ChoiceTargetKind.Damage, engine.State.PendingChoice!.Kind);
Assert.Equal(2, engine.State.PendingChoice.Options.Count);
Assert.True(engine.Execute(new ChooseCommand(0)).Accepted);
Assert.Null(engine.State.PendingChoice);
Assert.Null(engine.State.PendingTrigger);
Assert.Null(engine.State.Battle);
Assert.Equal(2, engine.State.Players[1].Damage.Count);
Assert.Contains(engine.State.Players[1].Drop, card => card.Id == chosen.Id);
}
[Fact]
public void Trigger_Power_Lasts_Until_End_Of_Turn()
{