Improve guard selection and ability cost payment
CI / test (push) Has been cancelled Details

This commit is contained in:
MpakobecEx 2026-10-23 20:43:48 +05:00
parent 4f87af4d23
commit 8618641205
8 changed files with 771 additions and 67 deletions

View File

@ -221,8 +221,10 @@ namespace Vanguard.Board3D
public sealed class BoardRenderState public sealed class BoardRenderState
{ {
public HashSet<BoardSlotKey> Highlights { get; } = new HashSet<BoardSlotKey>(); public HashSet<BoardSlotKey> Highlights { get; } = new HashSet<BoardSlotKey>();
public HashSet<BoardSlotKey> SelectedHighlights { get; } = new HashSet<BoardSlotKey>();
public HashSet<BoardSlotKey> DropTargets { get; } = new HashSet<BoardSlotKey>(); public HashSet<BoardSlotKey> DropTargets { get; } = new HashSet<BoardSlotKey>();
public bool GuardianDropAllowed { get; set; } public bool GuardianDropAllowed { get; set; }
public HashSet<int> AvailableHandInstanceIds { get; } = new HashSet<int>();
public HashSet<int> SelectedHandInstanceIds { get; } = new HashSet<int>(); public HashSet<int> SelectedHandInstanceIds { get; } = new HashSet<int>();
public bool MulliganSelecting { get; set; } public bool MulliganSelecting { get; set; }
public bool DamageChoice { get; set; } public bool DamageChoice { get; set; }

View File

@ -78,12 +78,12 @@ namespace Vanguard.Board3D
return view; return view;
} }
public void SetHighlighted(bool highlighted) public void SetHighlighted(bool highlighted, Color? color = null)
{ {
ApplyColor(highlighted); ApplyColor(highlighted, color ?? _highlightColor);
} }
private void ApplyColor(bool highlighted) private void ApplyColor(bool highlighted, Color? color = null)
{ {
if (_renderer == null) if (_renderer == null)
return; return;
@ -93,7 +93,7 @@ namespace Vanguard.Board3D
BoardMaterials.SetColor( BoardMaterials.SetColor(
_block, _block,
_renderer.sharedMaterial, _renderer.sharedMaterial,
highlighted ? _highlightColor : _baseColor); highlighted ? (color ?? _highlightColor) : _baseColor);
_renderer.SetPropertyBlock(_block); _renderer.SetPropertyBlock(_block);
} }
} }

View File

@ -29,6 +29,7 @@ namespace Vanguard.Board3D
private readonly BoardZoneView[] _damageZones = new BoardZoneView[2]; private readonly BoardZoneView[] _damageZones = new BoardZoneView[2];
private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2]; private readonly BoardZoneView[] _triggerZones = new BoardZoneView[2];
private readonly HashSet<BoardSlotKey> _activeHighlights = new HashSet<BoardSlotKey>(); private readonly HashSet<BoardSlotKey> _activeHighlights = new HashSet<BoardSlotKey>();
private readonly HashSet<BoardSlotKey> _selectedHighlights = new HashSet<BoardSlotKey>();
private readonly HashSet<BoardSlotKey> _validDropSlots = new HashSet<BoardSlotKey>(); private readonly HashSet<BoardSlotKey> _validDropSlots = new HashSet<BoardSlotKey>();
private readonly List<HandCardBinding> _handBindings = new List<HandCardBinding>(); private readonly List<HandCardBinding> _handBindings = new List<HandCardBinding>();
private bool _guardianDropAllowed; private bool _guardianDropAllowed;
@ -690,9 +691,11 @@ namespace Vanguard.Board3D
Stats = "P" + card.Power + " C" + card.Critical, Stats = "P" + card.Power + " C" + card.Critical,
StatsModified = card.Power != card.BasePower StatsModified = card.Power != card.BasePower
|| card.Critical != card.BaseCritical, || card.Critical != card.BaseCritical,
Highlight = state.Highlights.Contains(key) Highlight = state.SelectedHighlights.Contains(key)
? _config.highlightColor ? _config.selectedColor
: (Color?)null, : state.Highlights.Contains(key)
? _config.highlightColor
: (Color?)null,
}); });
} }
@ -706,13 +709,21 @@ namespace Vanguard.Board3D
var battleCards = view.Battle != null && view.Battle.AttackerPlayer != player.Index var battleCards = view.Battle != null && view.Battle.AttackerPlayer != player.Index
? view.Battle.GuardCards ?? new List<CardView>() ? view.Battle.GuardCards ?? new List<CardView>()
: new List<CardView>(); : new List<CardView>();
var total = guardians.Count + battleCards.Count;
// Guardian and battle guard lists contain the same cards after a
// guard command; render each physical instance only once.
var cards = guardians
.Concat(battleCards)
.GroupBy(card => card.InstanceId)
.Select(group => group.First())
.ToList();
var total = cards.Count;
if (total == 0) if (total == 0)
return; return;
for (var i = 0; i < guardians.Count; i++) for (var i = 0; i < total; i++)
{ {
var card = guardians[i]; var card = cards[i];
specs.Add(new CardSpec specs.Add(new CardSpec
{ {
Key = "G:" + player.Index + ":" + card.InstanceId, Key = "G:" + player.Index + ":" + card.InstanceId,
@ -727,27 +738,6 @@ namespace Vanguard.Board3D
Rested = false, Rested = false,
}); });
} }
for (var i = 0; i < battleCards.Count; i++)
{
var card = battleCards[i];
specs.Add(new CardSpec
{
Key = "BG:" + player.Index + ":" + card.InstanceId,
Ref = BoardCardRef.Guardian(player.Index, guardians.Count + i, card),
Art = ResolveArt(card.PrintingId),
Position = _layout.GuardianPosition(
localSeat,
guardians.Count + i,
total),
BaseRotation = Quaternion.Euler(
0f,
_layout.BaseYaw(localSeat) + 90f,
0f),
FaceUp = true,
Rested = false,
});
}
} }
private void AddDamageSpecs( private void AddDamageSpecs(
@ -843,7 +833,8 @@ namespace Vanguard.Board3D
Color? highlight = null; Color? highlight = null;
if (state.SelectedHandInstanceIds.Contains(card.InstanceId)) if (state.SelectedHandInstanceIds.Contains(card.InstanceId))
highlight = _config.selectedColor; highlight = _config.selectedColor;
else if (state.MulliganSelecting) else if (state.AvailableHandInstanceIds.Contains(card.InstanceId)
|| state.MulliganSelecting)
highlight = _config.highlightColor; highlight = _config.highlightColor;
specs.Add(new CardSpec specs.Add(new CardSpec
@ -1025,6 +1016,10 @@ namespace Vanguard.Board3D
foreach (var key in state.Highlights) foreach (var key in state.Highlights)
_activeHighlights.Add(key); _activeHighlights.Add(key);
_selectedHighlights.Clear();
foreach (var key in state.SelectedHighlights)
_selectedHighlights.Add(key);
_validDropSlots.Clear(); _validDropSlots.Clear();
foreach (var key in state.DropTargets) foreach (var key in state.DropTargets)
_validDropSlots.Add(key); _validDropSlots.Add(key);
@ -1037,9 +1032,13 @@ namespace Vanguard.Board3D
{ {
foreach (var slot in _slots) foreach (var slot in _slots)
{ {
var highlighted = _activeHighlights.Contains(slot.Key) var selected = _selectedHighlights.Contains(slot.Key);
var highlighted = selected
|| _activeHighlights.Contains(slot.Key)
|| (_dragging && _validDropSlots.Contains(slot.Key)); || (_dragging && _validDropSlots.Contains(slot.Key));
slot.SetHighlighted(highlighted); slot.SetHighlighted(
highlighted,
selected ? _config.selectedColor : _config.highlightColor);
} }
if (_guardianZone != null) if (_guardianZone != null)

View File

@ -95,6 +95,13 @@ namespace Vanguard.UI
private Action _discardCancelAction; private Action _discardCancelAction;
private int _pendingBoosterSlot = -1; private int _pendingBoosterSlot = -1;
private int _pendingBoostAttackerSlot = -2; private int _pendingBoostAttackerSlot = -2;
private readonly Dictionary<int, GuardIntent> _guardSelected =
new Dictionary<int, GuardIntent>();
private int _guardSelectedInterceptSlot = -1;
private int _guardDiscardInstanceId;
private string _guardDiscardPrintingId;
private ActivateAbilityIntent _pendingAbilityCost;
private int _abilityCostDiscardInstanceId;
private int? _triggerPowerSlot; private int? _triggerPowerSlot;
private readonly HashSet<int> _mulliganSelected = new HashSet<int>(); private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
private bool _mulliganUsed; private bool _mulliganUsed;
@ -285,6 +292,8 @@ namespace Vanguard.UI
_abilityDamageIndex = -1; _abilityDamageIndex = -1;
_pendingBoosterSlot = -1; _pendingBoosterSlot = -1;
_pendingBoostAttackerSlot = -2; _pendingBoostAttackerSlot = -2;
ClearGuardSelection();
ClearAbilityCostSelection();
_triggerPowerSlot = null; _triggerPowerSlot = null;
_showAllIntents = false; _showAllIntents = false;
_autoFinishMulligan = false; _autoFinishMulligan = false;
@ -747,6 +756,8 @@ namespace Vanguard.UI
_autoFinishMulliganAt = 0f; _autoFinishMulliganAt = 0f;
} }
FinishDiscardSelection(); FinishDiscardSelection();
PruneGuardSelection();
PruneAbilityCostSelection();
_triggerPowerSlot = null; _triggerPowerSlot = null;
PrepareTriggerSelection(); PrepareTriggerSelection();
if (_view.Clock != null) if (_view.Clock != null)
@ -1088,6 +1099,16 @@ namespace Vanguard.UI
if (_discardChoiceActive) if (_discardChoiceActive)
return; return;
if (_pendingAbilityCost != null
&& intent is not ActivateAbilityIntent
&& intent is not ConcedeIntent)
{
SetStatus(
"Сначала подтвердите оплату способности.",
new Color(1f, 0.70f, 0.35f));
return;
}
if (_abilityDamageIndex >= 0) if (_abilityDamageIndex >= 0)
{ {
if (intent is ActivateAbilityIntent activate) if (intent is ActivateAbilityIntent activate)
@ -1660,9 +1681,13 @@ namespace Vanguard.UI
var state = new BoardRenderState var state = new BoardRenderState
{ {
MulliganSelecting = IsMulliganSelecting(), MulliganSelecting = IsMulliganSelecting(),
DamageChoice = IsDamageChoice(), // Damage cards are highlighted only while an ability cost is
// being paid, not for ordinary damage choices.
DamageChoice = false,
AbilityDamageTargeting = IsAbilityDamageTargeting(), AbilityDamageTargeting = IsAbilityDamageTargeting(),
AbilityDamageIndex = _abilityDamageIndex, AbilityDamageIndex = IsAbilityDamageTargeting()
? _abilityDamageIndex
: -1,
MatchFinished = _view != null && _view.IsFinished, MatchFinished = _view != null && _view.IsFinished,
}; };
@ -1708,6 +1733,64 @@ namespace Vanguard.UI
} }
} }
if (_view != null)
{
var hand = _view.Players
.FirstOrDefault(player => player.Index == _view.You)
?.Hand ?? new List<CardView>();
var legal = _view.LegalIntents ?? new List<IntentPayload>();
if (IsGuardStep())
{
foreach (var guard in legal.OfType<GuardIntent>())
{
foreach (var card in hand)
{
if (card.PrintingId == guard.PrintingId)
state.AvailableHandInstanceIds.Add(card.InstanceId);
}
}
foreach (var instanceId in _guardSelected.Keys)
if (state.AvailableHandInstanceIds.Contains(instanceId))
state.SelectedHandInstanceIds.Add(instanceId);
foreach (var intercept in legal.OfType<InterceptIntent>())
{
state.Highlights.Add(new BoardSlotKey(_view.You, intercept.Slot));
}
if (_guardSelectedInterceptSlot >= 0)
{
state.SelectedHighlights.Add(
new BoardSlotKey(_view.You, _guardSelectedInterceptSlot));
}
}
if (_discardChoiceActive)
{
var discardPrintings = new HashSet<string>(
_discardAbilityChoices
.Select(item => item.DiscardPrintingId)
.Concat(_discardGuardChoices
.Select(item => item.DiscardPrintingId))
.Where(item => !string.IsNullOrEmpty(item)));
foreach (var card in hand)
{
if (discardPrintings.Contains(card.PrintingId))
state.AvailableHandInstanceIds.Add(card.InstanceId);
}
}
if (_pendingAbilityCost != null
&& _abilityCostDiscardInstanceId != 0
&& hand.Any(card =>
card.InstanceId == _abilityCostDiscardInstanceId))
{
state.SelectedHandInstanceIds.Add(_abilityCostDiscardInstanceId);
}
}
if (IsHandChoice()) if (IsHandChoice())
{ {
var hand = _view.Players var hand = _view.Players
@ -1769,15 +1852,17 @@ namespace Vanguard.UI
private bool IsAbilityDamageTargeting() private bool IsAbilityDamageTargeting()
{ {
if (_view == null || _view.Choice != null || _view.ActivePlayer != _view.You) if (_view == null
|| _view.Choice != null
|| _view.ActivePlayer != _view.You
|| _pendingAbilityCost == null
|| !RequiresCounterBlast(_pendingAbilityCost))
{
return false; return false;
}
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You); var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
if (self == null || self.FaceUpDamage.Count == 0) return self != null && self.FaceUpDamage.Count > 0;
return false;
return (_view.LegalIntents ?? new List<IntentPayload>())
.Any(intent => intent is ActivateAbilityIntent or ResolveAbilityIntent);
} }
private void OnBoard3DCardClicked(BoardCardRef card) private void OnBoard3DCardClicked(BoardCardRef card)
@ -1800,6 +1885,20 @@ namespace Vanguard.UI
if (_inputLock.IsLocked) if (_inputLock.IsLocked)
return; return;
if (_pendingAbilityCost != null && card.Zone != BoardZone.Damage)
{
SetStatus(
"Сначала подтвердите оплату способности.",
new Color(1f, 0.70f, 0.35f));
return;
}
if (IsGuardStep())
{
ToggleGuardSelection(card);
return;
}
var targetSelection = _pendingIntents.Count > 0 var targetSelection = _pendingIntents.Count > 0
|| IsMulliganSelecting() || IsMulliganSelecting()
|| IsDamageChoice() || IsDamageChoice()
@ -1858,6 +1957,368 @@ namespace Vanguard.UI
} }
} }
private bool IsGuardStep() =>
_view != null
&& _view.Step == GameStep.Guard
&& _view.ActivePlayer == _view.You;
private void ToggleGuardSelection(BoardCardRef card)
{
if (_view == null || card.Player != _view.You)
return;
if (card.Zone == BoardZone.Hand)
{
ToggleGuardHand(card);
return;
}
if (card.Zone is BoardZone.Vanguard or BoardZone.RearGuard)
{
var legal = _view.LegalIntents ?? new List<IntentPayload>();
var intercept = legal.OfType<InterceptIntent>()
.FirstOrDefault(intent => intent.Slot == card.Slot);
if (intercept == null)
{
SetStatus(
"Эта карта сейчас не может перехватить атаку.",
new Color(1f, 0.55f, 0.45f));
return;
}
_guardSelectedInterceptSlot =
_guardSelectedInterceptSlot == card.Slot ? -1 : card.Slot;
RefreshGuardSelection();
}
}
private void ToggleGuardHand(BoardCardRef card)
{
if (_guardSelected.Remove(card.InstanceId))
{
RefreshGuardSelection();
return;
}
var legal = _view.LegalIntents ?? new List<IntentPayload>();
var variants = legal.OfType<GuardIntent>()
.Where(intent => intent.PrintingId == card.PrintingId)
.ToList();
if (variants.Count == 0)
{
SetStatus(
"Эту карту нельзя использовать для защиты.",
new Color(1f, 0.55f, 0.45f));
return;
}
var distinct = variants
.GroupBy(intent => intent.DiscardPrintingId ?? string.Empty)
.Select(group => group.First())
.ToList();
var discardVariants = distinct
.Where(intent => !string.IsNullOrEmpty(intent.DiscardPrintingId))
.ToList();
if (discardVariants.Count == 0)
{
AddGuardSelection(card.InstanceId, distinct[0]);
return;
}
if (discardVariants.Count == 1)
{
AddGuardSelection(card.InstanceId, discardVariants[0]);
return;
}
_guardDiscardInstanceId = card.InstanceId;
_guardDiscardPrintingId = card.PrintingId;
StartDiscardSelection(
"Perfect Guard: выберите карту в руке для сброса.",
null,
discardVariants,
() =>
{
_guardDiscardInstanceId = 0;
_guardDiscardPrintingId = null;
RefreshGuardSelection();
});
Render();
}
private void AddGuardSelection(int instanceId, GuardIntent intent)
{
_guardSelected[instanceId] = intent;
RefreshGuardSelection();
}
private void RefreshGuardSelection()
{
if (_view == null)
return;
var count = _guardSelected.Count
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
SetStatus(
count == 0
? "Защита: выберите карты в руке или на поле."
: "Защита: выбрано " + count + " карт.",
new Color(0.72f, 0.86f, 1f));
Render();
}
private void ClearGuardSelection()
{
_guardSelected.Clear();
_guardSelectedInterceptSlot = -1;
_guardDiscardInstanceId = 0;
_guardDiscardPrintingId = null;
}
private void PruneGuardSelection()
{
if (!IsGuardStep())
{
ClearGuardSelection();
return;
}
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
var hand = self?.Hand ?? new List<CardView>();
var handIds = new HashSet<int>(hand.Select(card => card.InstanceId));
foreach (var instanceId in _guardSelected.Keys.ToList())
{
if (!handIds.Contains(instanceId))
_guardSelected.Remove(instanceId);
}
if (_guardSelectedInterceptSlot >= 0
&& !(_view.LegalIntents ?? new List<IntentPayload>())
.OfType<InterceptIntent>()
.Any(intent => intent.Slot == _guardSelectedInterceptSlot))
{
_guardSelectedInterceptSlot = -1;
}
}
private int GuardSelectionShield()
{
var shield = 0;
foreach (var guard in _guardSelected.Values)
{
var meta = ResolveCatalogItem(guard.PrintingId);
if (meta != null && !meta.IsSentinel)
shield += Math.Max(0, meta.Shield);
}
return shield;
}
private async void ConfirmGuardSelection()
{
if (_view == null
|| _connection == null
|| _cts == null
|| _sending)
{
return;
}
var intents = new List<IntentPayload>();
if (_guardSelectedInterceptSlot >= 0)
{
var intercept = (_view.LegalIntents ?? new List<IntentPayload>())
.OfType<InterceptIntent>()
.FirstOrDefault(intent => intent.Slot == _guardSelectedInterceptSlot);
if (intercept != null)
intents.Add(intercept);
}
intents.AddRange(_guardSelected.Values);
if (intents.Count == 0)
{
OnIntentClicked(new PassGuardIntent());
return;
}
ClearGuardSelection();
Render();
_sending = true;
SetStatus("Отправляем защиту…", Muted);
try
{
var acceptedAll = true;
foreach (var intent in intents)
{
var result = await _connection.SubmitAsync(intent, _cts.Token);
if (!result.Accepted)
{
acceptedAll = false;
SetStatus(
"Защита отклонена (" + result.Code + "): "
+ (result.Reason ?? "без причины"),
new Color(1f, 0.55f, 0.45f));
break;
}
}
if (acceptedAll)
{
// "Защищаться" is the final confirmation: add the selected
// guards and pass the guard step in one action.
await _connection.SubmitAsync(new PassGuardIntent(), _cts.Token);
}
await RequestSnapshotAsync();
}
catch (OperationCanceledException)
{
// экран закрыли
}
catch (Exception exception)
{
Debug.LogException(exception);
SetStatus(
"Ошибка отправки защиты: " + exception.Message,
new Color(1f, 0.55f, 0.45f));
}
finally
{
_sending = false;
if (_view != null)
Render();
}
}
private bool RequiresCounterBlast(ActivateAbilityIntent intent)
{
if (intent == null || _view == null)
return false;
var card = InstanceAt(_view.You, intent.InstanceId);
var meta = ResolveCatalogItem(card?.PrintingId ?? string.Empty);
if (meta == null)
return false;
var text = ((meta.TextEn ?? string.Empty) + " "
+ (meta.TextRu ?? string.Empty)).ToLowerInvariant();
return text.Contains("counter-blast")
|| text.Contains("counter blast")
|| text.Contains("встречн");
}
private void ActivateAbility(ActivateAbilityIntent intent)
{
if (intent == null || _view == null || _pendingAbilityCost != null)
return;
if (!string.IsNullOrEmpty(intent.DiscardPrintingId))
{
var legal = _view.LegalIntents ?? new List<IntentPayload>();
var variants = legal.OfType<ActivateAbilityIntent>()
.Where(variant => variant.InstanceId == intent.InstanceId
&& !string.IsNullOrEmpty(variant.DiscardPrintingId))
.ToList();
_pendingAbilityCost = null;
_abilityCostDiscardInstanceId = 0;
_abilityDamageIndex = -1;
StartDiscardSelection(
"Выберите карту в руке для сброса.",
variants,
null);
Render();
return;
}
if (RequiresCounterBlast(intent))
{
BeginAbilityCost(intent);
return;
}
OnIntentClicked(intent);
}
private void BeginAbilityCost(ActivateAbilityIntent intent)
{
_pendingAbilityCost = intent;
_abilityCostDiscardInstanceId = 0;
_abilityDamageIndex = -1;
_pendingIntents.Clear();
_highlightSlots.Clear();
SetStatus(
"Оплата способности: выберите карты для цены.",
new Color(0.98f, 0.82f, 0.42f));
Render();
}
private void ConfirmAbilityCost()
{
if (_pendingAbilityCost == null || _view == null || _sending)
return;
if (RequiresCounterBlast(_pendingAbilityCost)
&& _abilityDamageIndex < 0)
{
SetStatus(
"Выберите карту урона для Counter Blast.",
new Color(1f, 0.70f, 0.35f));
return;
}
var activate = new ActivateAbilityIntent
{
InstanceId = _pendingAbilityCost.InstanceId,
DiscardPrintingId = _pendingAbilityCost.DiscardPrintingId,
DamageIndex = _abilityDamageIndex,
};
ClearAbilityCostSelection();
OnIntentClicked(activate);
}
private void ClearAbilityCostSelection()
{
_pendingAbilityCost = null;
_abilityCostDiscardInstanceId = 0;
_abilityDamageIndex = -1;
}
private void PruneAbilityCostSelection()
{
if (_pendingAbilityCost == null)
return;
if (_view == null || _view.ActivePlayer != _view.You)
{
ClearAbilityCostSelection();
return;
}
var stillLegal = (_view.LegalIntents ?? new List<IntentPayload>())
.OfType<ActivateAbilityIntent>()
.Any(intent => intent.InstanceId == _pendingAbilityCost.InstanceId);
if (!stillLegal)
ClearAbilityCostSelection();
}
private bool IsAbilityCostReady()
{
if (_pendingAbilityCost == null)
return false;
return !RequiresCounterBlast(_pendingAbilityCost)
|| _abilityDamageIndex >= 0;
}
private void ToggleGuardFromDrop(BoardCardRef source)
{
if (!IsGuardStep())
return;
ToggleGuardSelection(source);
}
private void OpenCardMenu(BoardCardRef card) private void OpenCardMenu(BoardCardRef card)
{ if (_view == null || _board3D == null) { if (_view == null || _board3D == null)
return; return;
@ -2140,7 +2601,7 @@ namespace Vanguard.UI
.ToList(); .ToList();
if (discardVariants.Count == 0) if (discardVariants.Count == 0)
{ {
OnIntentClicked(free ?? distinct[0]); ActivateAbility(free ?? distinct[0]);
return; return;
} }
@ -2152,7 +2613,7 @@ namespace Vanguard.UI
{ {
new CardActionMenuItem( new CardActionMenuItem(
"Без сброса", "Без сброса",
() => OnIntentClicked(free)), () => ActivateAbility(free)),
new CardActionMenuItem( new CardActionMenuItem(
"Сбросить карту в руке", "Сбросить карту в руке",
() => StartDiscardSelection( () => StartDiscardSelection(
@ -2209,8 +2670,12 @@ namespace Vanguard.UI
item => item.DiscardPrintingId == printing); item => item.DiscardPrintingId == printing);
if (ability != null) if (ability != null)
{ {
var discardInstanceId = card.InstanceId;
FinishDiscardSelection(); FinishDiscardSelection();
OnIntentClicked(ability); _pendingAbilityCost = ability;
_abilityCostDiscardInstanceId = discardInstanceId;
_abilityDamageIndex = -1;
Render();
return; return;
} }
@ -2218,7 +2683,16 @@ namespace Vanguard.UI
item => item.DiscardPrintingId == printing); item => item.DiscardPrintingId == printing);
if (guard != null) if (guard != null)
{ {
var guardInstanceId = _guardDiscardInstanceId;
FinishDiscardSelection(); FinishDiscardSelection();
if (guardInstanceId != 0)
{
_guardDiscardInstanceId = 0;
_guardDiscardPrintingId = null;
AddGuardSelection(guardInstanceId, guard);
return;
}
OnIntentClicked(guard); OnIntentClicked(guard);
return; return;
} }
@ -2253,6 +2727,8 @@ namespace Vanguard.UI
_discardGuardChoices.Clear(); _discardGuardChoices.Clear();
_discardCancelAction = null; _discardCancelAction = null;
_discardPrompt = null; _discardPrompt = null;
_guardDiscardInstanceId = 0;
_guardDiscardPrintingId = null;
} }
private void CancelDiscardSelection() private void CancelDiscardSelection()
@ -2354,6 +2830,14 @@ namespace Vanguard.UI
if (_view == null || _sending || _inputLock.IsLocked) if (_view == null || _sending || _inputLock.IsLocked)
return; return;
if (IsGuardStep()
&& target.Zone == BoardZone.Guardian
&& (source.Zone == BoardZone.Hand || source.IsField))
{
ToggleGuardFromDrop(source);
return;
}
var legal = _view.LegalIntents ?? new List<IntentPayload>(); var legal = _view.LegalIntents ?? new List<IntentPayload>();
IntentPayload intent = null; IntentPayload intent = null;
@ -2486,6 +2970,30 @@ namespace Vanguard.UI
if (_view == null || _inputLock.IsLocked) if (_view == null || _inputLock.IsLocked)
return; return;
if (_discardChoiceActive)
{
CancelDiscardSelection();
return;
}
if (_pendingAbilityCost != null)
{
ClearAbilityCostSelection();
SetStatus("Оплата способности отменена.", Muted);
Render();
return;
}
if (IsGuardStep()
&& (_guardSelected.Count > 0
|| _guardSelectedInterceptSlot >= 0))
{
ClearGuardSelection();
SetStatus("Выбор защиты снят.", Muted);
Render();
return;
}
if (card.Zone == BoardZone.Vanguard) if (card.Zone == BoardZone.Vanguard)
OpenZone(BoardZone.Soul, card.Player); OpenZone(BoardZone.Soul, card.Player);
} }
@ -2566,6 +3074,15 @@ namespace Vanguard.UI
? "Выберите карту в руке для сброса." ? "Выберите карту в руке для сброса."
: _discardPrompt; : _discardPrompt;
} }
else if (_pendingAbilityCost != null)
{
title = "ОПЛАТА";
hint = RequiresCounterBlast(_pendingAbilityCost)
? _abilityDamageIndex < 0
? "Выберите карту урона для Counter Blast."
: "Оплата выбрана. Подтвердите в панели действий."
: "Оплата выбрана. Подтвердите в панели действий.";
}
else if (_pendingBoosterSlot >= 0 && _pendingIntents.Count > 0) else if (_pendingBoosterSlot >= 0 && _pendingIntents.Count > 0)
{ {
title = "ПОДДЕРЖКА"; title = "ПОДДЕРЖКА";
@ -2618,7 +3135,12 @@ namespace Vanguard.UI
else if (_view.Step == GameStep.Guard) else if (_view.Step == GameStep.Guard)
{ {
title = "ЗАЩИТА"; title = "ЗАЩИТА";
hint = "Выберите защиту или не защищайтесь."; var guardCount = _guardSelected.Count
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
hint = guardCount == 0
? "Кликните карты в руке или на поле для защиты."
: "Выбрано карт: " + guardCount
+ ". Щит: +" + GuardSelectionShield() + ".";
} }
else if (_view.Phase == GamePhase.Battle) else if (_view.Phase == GamePhase.Battle)
{ {
@ -2661,7 +3183,7 @@ namespace Vanguard.UI
_phasePanel.anchorMax = new Vector2(1f, 0.5f); _phasePanel.anchorMax = new Vector2(1f, 0.5f);
_phasePanel.pivot = new Vector2(1f, 0.5f); _phasePanel.pivot = new Vector2(1f, 0.5f);
_phasePanel.anchoredPosition = new Vector2(-468f, 0f); _phasePanel.anchoredPosition = new Vector2(-468f, 0f);
_phasePanel.sizeDelta = new Vector2(156f, 188f); _phasePanel.sizeDelta = new Vector2(190f, 220f);
UiKit.AddVerticalLayout(_phasePanel, 8, 6); UiKit.AddVerticalLayout(_phasePanel, 8, 6);
_phasePanel.gameObject.SetActive(false); _phasePanel.gameObject.SetActive(false);
} }
@ -2672,12 +3194,16 @@ namespace Vanguard.UI
return; return;
ClearChildren(_phasePanel); ClearChildren(_phasePanel);
if (view == null || _pendingIntents.Count > 0) if (view == null
|| _pendingIntents.Count > 0
|| _discardChoiceActive)
{ {
_phasePanel.gameObject.SetActive(false); _phasePanel.gameObject.SetActive(false);
return; return;
} }
var isGuardStep = IsGuardStep();
var isAbilityCost = _pendingAbilityCost != null;
var intents = (view.LegalIntents ?? new List<IntentPayload>()) var intents = (view.LegalIntents ?? new List<IntentPayload>())
.Where(IsPhaseIntent) .Where(IsPhaseIntent)
.Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1)) .Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1))
@ -2685,7 +3211,7 @@ namespace Vanguard.UI
// inactive finish button after the exchange is misleading. // inactive finish button after the exchange is misleading.
.Where(intent => !(intent is FinishMulliganIntent && _mulliganUsed)) .Where(intent => !(intent is FinishMulliganIntent && _mulliganUsed))
.ToList(); .ToList();
if (intents.Count == 0) if (intents.Count == 0 && !isGuardStep && !isAbilityCost)
{ {
_phasePanel.gameObject.SetActive(false); _phasePanel.gameObject.SetActive(false);
return; return;
@ -2695,12 +3221,20 @@ namespace Vanguard.UI
var title = UiKit.CreateText( var title = UiKit.CreateText(
_phasePanel, _phasePanel,
"PhaseTitle", "PhaseTitle",
"ФАЗЫ", "ДЕЙСТВИЯ",
14, 14,
new Color(0.72f, 0.80f, 0.94f)); new Color(0.72f, 0.80f, 0.94f));
title.fontStyle = FontStyle.Bold; title.fontStyle = FontStyle.Bold;
UiKit.SetPreferredHeight(title, 22f); UiKit.SetPreferredHeight(title, 22f);
if (isGuardStep)
AddGuardPhaseButton();
if (isAbilityCost)
{
AddAbilityCostPhaseButton();
return;
}
foreach (var ride in (view.LegalIntents ?? new List<IntentPayload>()) foreach (var ride in (view.LegalIntents ?? new List<IntentPayload>())
.OfType<RideIntent>() .OfType<RideIntent>()
.GroupBy(item => item.PrintingId) .GroupBy(item => item.PrintingId)
@ -2744,6 +3278,45 @@ namespace Vanguard.UI
} }
} }
private void AddGuardPhaseButton()
{
var count = _guardSelected.Count
+ (_guardSelectedInterceptSlot >= 0 ? 1 : 0);
var button = UiKit.CreateButton(
_phasePanel,
"Phase_Guard",
count == 0 ? "Не защищаться" : "Защищаться",
() =>
{
if (_guardSelected.Count == 0
&& _guardSelectedInterceptSlot < 0)
{
OnIntentClicked(new PassGuardIntent());
}
else
{
ConfirmGuardSelection();
}
},
14);
button.interactable = !_inputLock.IsLocked && !_sending;
UiKit.SetPreferredHeight(button, 40f);
}
private void AddAbilityCostPhaseButton()
{
var button = UiKit.CreateButton(
_phasePanel,
"Phase_ConfirmAbilityCost",
"Подтвердить оплату",
ConfirmAbilityCost,
14);
button.interactable = !_inputLock.IsLocked
&& !_sending
&& IsAbilityCostReady();
UiKit.SetPreferredHeight(button, 40f);
}
private static bool IsPhaseIntent(IntentPayload intent) => private static bool IsPhaseIntent(IntentPayload intent) =>
intent is FinishMulliganIntent or SkipRideIntent intent is FinishMulliganIntent or SkipRideIntent
or EnterBattleIntent or EndTurnIntent; or EnterBattleIntent or EndTurnIntent;
@ -2838,7 +3411,7 @@ namespace Vanguard.UI
} }
var attackPower = attacker.Power; var attackPower = attacker.Power;
var shield = defender.Power + battle.GuardShield; var shield = defender.Power + battle.GuardShield + GuardSelectionShield();
var text = battle.Nullified var text = battle.Nullified
? "POWER " + attackPower + " · NULLIFIED" ? "POWER " + attackPower + " · NULLIFIED"
: "POWER " + attackPower + " vs SHIELD " + shield; : "POWER " + attackPower + " vs SHIELD " + shield;
@ -3073,7 +3646,10 @@ namespace Vanguard.UI
if (sources.Count == 1) if (sources.Count == 1)
{ {
ClearSelection(rebuild: false); ClearSelection(rebuild: false);
OnIntentClicked(sources[0]); if (sources[0] is ActivateAbilityIntent ability)
ActivateAbility(ability);
else
OnIntentClicked(sources[0]);
return; return;
} }
@ -3106,7 +3682,10 @@ namespace Vanguard.UI
if (withTargets.Count == 0) if (withTargets.Count == 0)
{ {
ClearSelection(rebuild: false); ClearSelection(rebuild: false);
OnIntentClicked(sources[0]); if (sources[0] is ActivateAbilityIntent ability)
ActivateAbility(ability);
else
OnIntentClicked(sources[0]);
return; return;
} }
@ -3354,7 +3933,6 @@ namespace Vanguard.UI
var legal = view.LegalIntents ?? new List<IntentPayload>(); var legal = view.LegalIntents ?? new List<IntentPayload>();
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
var abilities = playable.OfType<ActivateAbilityIntent>().ToList(); var abilities = playable.OfType<ActivateAbilityIntent>().ToList();
var guards = playable.OfType<GuardIntent>().ToList();
var rides = playable.OfType<RideIntent>().ToList(); var rides = playable.OfType<RideIntent>().ToList();
var board = playable var board = playable
.Where(intent => IsBoardIntent(intent) .Where(intent => IsBoardIntent(intent)
@ -3365,6 +3943,7 @@ namespace Vanguard.UI
var global = playable var global = playable
.Where(intent => !IsBoardIntent(intent) .Where(intent => !IsBoardIntent(intent)
&& !IsPhaseIntent(intent) && !IsPhaseIntent(intent)
&& intent is not PassGuardIntent
&& intent is not ActivateAbilityIntent && intent is not ActivateAbilityIntent
&& intent is not ResolveAbilityIntent && intent is not ResolveAbilityIntent
&& !(intent is ChooseIntent && !(intent is ChooseIntent
@ -3464,12 +4043,9 @@ namespace Vanguard.UI
UiKit.SetPreferredHeight(toggle, 38f); UiKit.SetPreferredHeight(toggle, 38f);
} }
if (abilities.Count > 0) if (abilities.Count > 0 && _pendingAbilityCost == null)
AddAbilityButton(abilities); AddAbilityButton(abilities);
if (guards.Count > 0)
AddGuardButton(guards);
if (rides.Count > 0) if (rides.Count > 0)
AddRideButtons(rides); AddRideButtons(rides);

View File

@ -258,6 +258,71 @@ namespace Vanguard.Tests
} }
} }
[Test]
public void Guard_Control_Is_One_Button_In_Action_Panel()
{
var config = new AppConfig();
config.Load();
var tokens = new TokenStore();
var api = new ApiClient(config, tokens);
var player = new PlayerState();
var auth = new AuthService(api, tokens, player);
var meta = new MetaService(api, player);
var canvas = UiKit.CreateCanvas("Guard Panel Test Canvas");
try
{
var battle = new BattleView(canvas.transform, meta, () => { });
var match = new MatchView
{
You = 0,
ActivePlayer = 0,
Phase = GamePhase.Battle,
Step = GameStep.Guard,
LegalIntents = new List<IntentPayload>
{
new GuardIntent { PrintingId = "BT01/004EN" },
new PassGuardIntent(),
new ConcedeIntent(),
},
};
var viewField = typeof(BattleView)
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!;
var rebuild = typeof(BattleView)
.GetMethod("RebuildPhaseControls", BindingFlags.Instance | BindingFlags.NonPublic)!;
viewField.SetValue(battle, match);
rebuild.Invoke(battle, new object[] { match });
Assert.IsTrue(
FindRect("PhasePanel").gameObject.activeSelf,
"Guard controls must be in the action panel");
var guardButton = Resources.FindObjectsOfTypeAll<Button>()
.FirstOrDefault(button => button.name == "Phase_Guard");
Assert.IsNotNull(guardButton);
Assert.AreEqual(
"Не защищаться",
guardButton.GetComponentInChildren<Text>().text);
Assert.IsFalse(
Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "GuardAction"),
"Legacy guard button must not remain in the side panel");
Assert.IsFalse(
Resources.FindObjectsOfTypeAll<GameObject>()
.Any(go => go.name == "Phase_PassGuardIntent"),
"Pass guard must be the same single button");
Assert.AreEqual(
"ДЕЙСТВИЯ",
Resources.FindObjectsOfTypeAll<Text>()
.First(text => text.name == "PhaseTitle").text);
}
finally
{
Object.DestroyImmediate(canvas.gameObject);
Cleanup();
}
}
[Test] [Test]
public void Optional_Ability_Shows_Overlay_Instead_Of_Action_Panel() public void Optional_Ability_Shows_Overlay_Instead_Of_Action_Panel()
{ {

View File

@ -100,6 +100,42 @@ namespace Vanguard.Tests
} }
} }
[Test]
public void Guardian_And_Battle_Guard_Cards_Are_Not_Duplicated()
{
var config = PlaymatLayoutConfig.CreateDefault();
var board = GameBoard3D.Create(null, config);
try
{
board.SetVisible(true);
var view = BuildView(you: 0);
var self = view.Players.Single(player => player.Index == 0);
view.Battle = new BattleView
{
AttackerPlayer = 1,
AttackerSlot = 3,
TargetSlot = -1,
GuardCards = self.Guardian,
};
board.Render(view, new BoardRenderState());
var guardianCards = ActiveCards()
.Where(card => card.Ref.Zone == BoardZone.Guardian
&& card.Ref.Player == 0
&& card.Ref.InstanceId == 7)
.ToList();
Assert.AreEqual(
1,
guardianCards.Count,
"A guard card present in both Guardian and Battle.GuardCards must render once");
}
finally
{
Object.DestroyImmediate(board.gameObject);
}
}
[Test] [Test]
public void Hand_Is_Anchored_To_Camera_Bottom_Center_And_Faces_Player() public void Hand_Is_Anchored_To_Camera_Bottom_Center_And_Faces_Player()
{ {

View File

@ -1,11 +1,28 @@
# Передача сессии # Передача сессии
Состояние проекта на **22 октября 2026**. Документ нужен, чтобы новая сессия Состояние проекта на **23 октября 2026**. Документ нужен, чтобы новая сессия
продолжила работу без повторного разбора истории. Краткая оперативная продолжила работу без повторного разбора истории. Краткая оперативная
сводка — `docs/SESSION-STATE.md`; этот файл хранит подробную историю. сводка — `docs/SESSION-STATE.md`; этот файл хранит подробную историю.
--- ---
## 2026-10-23 — защита и оплата способностей
- Панель «Фазы» переименована в «Действия»; защита перенесена туда.
- Защита выбирается кликом/драгом по картам в руке и перехватчикам на поле.
В панели одна кнопка: «Не защищаться» без выбора, «Защищаться» с выбранными
картами. Щит выбранных карт сразу входит в `POWER vs SHIELD`; после
подтверждения карты отправляются в Guardian, шаг защиты завершается.
- Дубли карт в Guardian (`player.Guardian` + `Battle.GuardCards`) устранены
дедупликацией по `InstanceId`.
- Оплата активируемых способностей: сброс выбирается в руке, Counter Blast —
кликом по урону. Доступные и выбранные карты цены подсвечиваются; оплата
подтверждается кнопкой «Подтвердить оплату» в панели «Действия».
Урон больше не подсвечивается вне режима оплаты. Правый клик отменяет выбор.
- Тесты клиента: **38/38** EditMode; `SCENE_SMOKE` и `PLAYMAT_SMOKE` успешны.
---
## 1. Проект ## 1. Проект
Фан-ремастер **Cardfight!! Vanguard** базовой эры (2011), формат **Classic**. Фан-ремастер **Cardfight!! Vanguard** базовой эры (2011), формат **Classic**.

View File

@ -87,13 +87,21 @@
`P/C`. `P/C`.
- Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом - Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом
ходу переход в Battle не предлагается; атака по-прежнему запрещена. ходу переход в Battle не предлагается; атака по-прежнему запрещена.
Кнопки «Сменить: карта» есть и в панели действий, и в панели «Фазы». Кнопки «Сменить: карта» есть и в панели действий, и в панели «Действия».
- Способности: кнопка «Активировать способность» одна на карту; при - Способности: кнопка «Активировать способность» одна на карту; при
нескольких вариантах открывается overlay выбора способности. Карта для нескольких вариантах открывается overlay выбора способности. Карта для
сброса выбирается **кликом по карте в руке**, а не пунктом меню. сброса выбирается **кликом по карте в руке**, а не пунктом меню.
Perfect Guard также просит выбрать карту сброса в руке. Perfect Guard также просит выбрать карту сброса в руке.
- В фазе защиты доступны «Защититься» (если есть допустимые карты) и - Оплата способности: выбранные карты цены подсвечиваются в руке, а для
«Не защищаться»; карта защиты и карта сброса выбираются в руке. Counter Blast — доступные и выбранные карты в зоне урона. Подтверждение
оплаты — кнопка «Подтвердить оплату» в панели «Действия»; правый клик
отменяет выбор.
- Защита: в панели «Действия» одна кнопка — «Не защищаться» без выбора и
«Защищаться» с выбранными картами. Карты защиты и перехвата выбираются
кликом/драгом по объектам; допустимые подсвечиваются, выбранные — зелёным.
Щит выбранных карт сразу учитывается в счётчике; после подтверждения карты
уходят в зону защиты, шаг защиты завершается. Дубли карт в Guardian
устранены.
- Локальный standalone работает в фоне (`runInBackground`), чтобы heartbeat - Локальный standalone работает в фоне (`runInBackground`), чтобы heartbeat
WebSocket не останавливался при потере фокуса. WebSocket не останавливался при потере фокуса.
- При потере соединения весь бой блокируется полноэкранным overlay - При потере соединения весь бой блокируется полноэкранным overlay
@ -102,9 +110,9 @@
Кнопка внутри overlay позволяет выйти в меню. Кнопка внутри overlay позволяет выйти в меню.
- Правую панель действий можно временно скрыть кнопкой «Скрыть панель» - Правую панель действий можно временно скрыть кнопкой «Скрыть панель»
в верхней строке; поле остаётся видимым, вернуть — «Показать панель». в верхней строке; поле остаётся видимым, вернуть — «Показать панель».
При скрытии панель фаз и блоки противника сдвигаются вправо, а рука При скрытии панель действий и блоки противника сдвигаются вправо, а рука
центрируется по горизонтали; при показе возвращается влево. центрируется по горизонтали; при показе возвращается влево.
- Кнопка муллигана находится в панели «Фазы» и динамическая: без выбора — - Кнопка муллигана находится в панели «Действия» и динамическая: без выбора —
«Закончить муллиган», с выбором — «Заменить X карт». «Закончить муллиган», с выбором — «Заменить X карт».
- Trigger-зона у противника расположена над его колодой по направлению - Trigger-зона у противника расположена над его колодой по направлению
к центру стола (зеркально своей), а не за дальним краем. к центру стола (зеркально своей), а не за дальним краем.
@ -115,7 +123,7 @@
только закрытые карты. Душа — юниты под VC: отдельной стопки нет, только закрытые карты. Душа — юниты под VC: отдельной стопки нет,
ПКМ по VC открывает список. Дроп по умолчанию пуст; сверху показывает ПКМ по VC открывает список. Дроп по умолчанию пуст; сверху показывает
последнюю карту, ЛКМ — список всего дропа. Колода и дроп поменяны последнюю карту, ЛКМ — список всего дропа. Колода и дроп поменяны
местами (колода ближе к игроку). Справа от круга защиты — панель фаз: местами (колода ближе к игроку). Справа от круга защиты — панель действий:
конец муллигана (авто после обмена), скип смены, конец главной/боевой; конец муллигана (авто после обмена), скип смены, конец главной/боевой;
боевая завершается сама, если нет атак и активаций. Рука противника — боевая завершается сама, если нет атак и активаций. Рука противника —
веер закрытых карт у дальнего края стола, только рубашки. веер закрытых карт у дальнего края стола, только рубашки.
@ -229,13 +237,14 @@ Standalone Windows:
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
``` ```
Клиентские проверки: **36/36** EditMode (сборка всех экранов, импорт арта, Клиентские проверки: **38/38** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк, live-переключение языка, рендер перевод статических/динамических строк, live-переключение языка, рендер
3D-доски с уроном и отдыхающей картой, геометрия поля, правила 3D-доски с уроном и отдыхающей картой, геометрия поля, правила
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
CardListView/Detail, зоны и рука противника, InputLock, контекстное меню, CardListView/Detail, зоны и рука противника, InputLock, контекстное меню,
стрелка атаки, prefab-оверлеи, раскладка на 1920×1080, авто-завершение стрелка атаки, prefab-оверлеи, раскладка на 1920×1080, авто-завершение
муллигана без кнопки после обмена), `SCENE_SMOKE муллигана без кнопки после обмена, одна кнопка защиты в панели действий,
отсутствие дублей карт в Guardian), `SCENE_SMOKE
success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`. success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены