Fix battle card art, mulligan, damage targeting and rest
CI / test (push) Successful in 1m49s Details

This commit is contained in:
MpakobecEx 2026-10-22 02:17:21 +05:00
parent f5b7f3e2d8
commit c0c00ccc4c
17 changed files with 349 additions and 69 deletions

View File

@ -275,9 +275,13 @@ drive/damage-анимации и звук — следующие. Финальн
- [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева, - [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева,
колода/дроп справа, зеркально у противника, зона защиты между полями. колода/дроп справа, зеркально у противника, зона защиты между полями.
- [x] Карты: реальный арт из `imageKey`, рамка клана, рубашка, имя, ранг, - [x] Карты: реальный арт на всю карту, рамка клана, рубашка, имя, ранг,
триггер, сила/крит, REST/скрытая карта; процедурный fallback. триггер, сила/крит; REST поворачивает карту на бок; процедурный fallback.
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»). - [x] Старт: маллиган с выбором карт в руке, возвратом в колоду,
перемешиванием и добиром до пяти.
- [x] Зона урона: видимые карты на боку; heal и Counter Blast выбирают
карту урона кликом.
- [x] Позванные rear-guard'ы входят стоящими; rest — после атаки/буста.
- [x] Ввод: кнопки легальных интентов; клик по картам/зонам, - [x] Ввод: кнопки легальных интентов; клик по картам/зонам,
подсветка целей, группировка действий, выбор силы/эффекта триггера подсветка целей, группировка действий, выбор силы/эффекта триггера
и журнал боя. и журнал боя.

View File

@ -21,7 +21,7 @@
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` | | CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
| EF-миграции | готово (`InitialCreate`) | | EF-миграции | готово (`InitialCreate`) |
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` | | Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с реальными артами, анимациями и RU/EN | | Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с артами, маллиганом, зоной урона, анимациями и RU/EN |
| Лаунчер | не начато | | Лаунчер | не начато |
## Требования ## Требования

View File

@ -72,6 +72,7 @@ namespace Vanguard.UI
private int _pendingBoosterSlot = -1; private int _pendingBoosterSlot = -1;
private int? _triggerPowerSlot; private int? _triggerPowerSlot;
private readonly HashSet<int> _mulliganSelected = new HashSet<int>(); private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
private int _abilityDamageIndex = -1;
private readonly Dictionary<string, RectTransform> _cardPanels = private readonly Dictionary<string, RectTransform> _cardPanels =
new Dictionary<string, RectTransform>(StringComparer.Ordinal); new Dictionary<string, RectTransform>(StringComparer.Ordinal);
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>(); private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
@ -207,6 +208,7 @@ namespace Vanguard.UI
_pendingIntents.Clear(); _pendingIntents.Clear();
_highlightKeys.Clear(); _highlightKeys.Clear();
_mulliganSelected.Clear(); _mulliganSelected.Clear();
_abilityDamageIndex = -1;
_pendingBoosterSlot = -1; _pendingBoosterSlot = -1;
_triggerPowerSlot = null; _triggerPowerSlot = null;
_showAllIntents = false; _showAllIntents = false;
@ -963,6 +965,15 @@ namespace Vanguard.UI
if (_sending || _connection == null || _cts == null || _view == null) if (_sending || _connection == null || _cts == null || _view == null)
return; return;
if (_abilityDamageIndex >= 0)
{
if (intent is ActivateAbilityIntent activate)
activate.DamageIndex = _abilityDamageIndex;
else if (intent is ResolveAbilityIntent resolve)
resolve.DamageIndex = _abilityDamageIndex;
}
_abilityDamageIndex = -1;
_pendingIntents.Clear(); _pendingIntents.Clear();
_highlightKeys.Clear(); _highlightKeys.Clear();
_pendingBoosterSlot = -1; _pendingBoosterSlot = -1;
@ -1215,16 +1226,17 @@ namespace Vanguard.UI
gridElement.preferredHeight = 176f; gridElement.preferredHeight = 176f;
var damageChoice = IsDamageChoice(); var damageChoice = IsDamageChoice();
var abilityTargeting = IsAbilityDamageTargeting();
var displayIndex = 0; var displayIndex = 0;
for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++) for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++)
{ {
AddDamageCard(grid, displayIndex, null, false, damageChoice); AddDamageCard(grid, displayIndex, null, false, damageChoice, abilityTargeting);
} }
foreach (var card in player.FaceUpDamage) foreach (var card in player.FaceUpDamage)
{ {
AddDamageCard(grid, displayIndex, card, true, damageChoice); AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting: false);
displayIndex++; displayIndex++;
} }
} }
@ -1234,7 +1246,8 @@ namespace Vanguard.UI
int displayIndex, int displayIndex,
CardView card, CardView card,
bool faceUp, bool faceUp,
bool damageChoice) bool damageChoice,
bool abilityTargeting)
{ {
var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white); var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white);
var rect = panel; var rect = panel;
@ -1267,19 +1280,23 @@ namespace Vanguard.UI
image.color = Color.white; image.color = Color.white;
} }
var canClick = damageChoice || abilityTargeting;
var selected = abilityTargeting && _abilityDamageIndex == displayIndex;
var outline = panel.gameObject.AddComponent<Outline>(); var outline = panel.gameObject.AddComponent<Outline>();
outline.effectColor = damageChoice outline.effectColor = selected
? new Color(0.98f, 0.82f, 0.42f) ? new Color(0.45f, 0.95f, 0.62f)
: new Color(0.42f, 0.18f, 0.18f); : canClick
outline.effectDistance = damageChoice ? new Color(0.98f, 0.82f, 0.42f)
? new Vector2(3f, -3f) : new Color(0.42f, 0.18f, 0.18f);
outline.effectDistance = canClick
? new Vector2(selected ? 4f : 3f, selected ? -4f : -3f)
: new Vector2(1f, -1f); : new Vector2(1f, -1f);
var button = panel.gameObject.AddComponent<Button>(); var button = panel.gameObject.AddComponent<Button>();
button.targetGraphic = image; button.targetGraphic = image;
var capturedIndex = displayIndex; var capturedIndex = displayIndex;
button.onClick.AddListener(() => OnDamageClicked(capturedIndex)); button.onClick.AddListener(() => OnDamageClicked(capturedIndex));
button.interactable = damageChoice; button.interactable = canClick;
} }
private bool IsDamageChoice() => private bool IsDamageChoice() =>
@ -1287,19 +1304,46 @@ namespace Vanguard.UI
&& _view.Choice.Prompt != null && _view.Choice.Prompt != null
&& _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0; && _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0;
private bool IsAbilityDamageTargeting()
{
if (_view == null || _view.Choice != null || _view.ActivePlayer != _view.You)
return false;
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
if (self == null || self.FaceDownDamage == 0)
return false;
return (_view.LegalIntents ?? new List<IntentPayload>())
.Any(intent => intent is ActivateAbilityIntent or ResolveAbilityIntent);
}
private void OnDamageClicked(int displayIndex) private void OnDamageClicked(int displayIndex)
{ {
if (_view?.Choice?.Options == null || !IsDamageChoice()) if (IsDamageChoice())
{
if (_view?.Choice?.Options == null)
return;
for (var i = 0; i < _view.Choice.Options.Count; i++)
{
if (_view.Choice.Options[i].Slot == displayIndex)
{
OnIntentClicked(new ChooseIntent { Index = i });
return;
}
}
return;
}
if (!IsAbilityDamageTargeting())
return; return;
for (var i = 0; i < _view.Choice.Options.Count; i++) _abilityDamageIndex = displayIndex;
{ SetStatus(
if (_view.Choice.Options[i].Slot == displayIndex) "Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".",
{ new Color(0.45f, 0.95f, 0.62f));
OnIntentClicked(new ChooseIntent { Index = i }); Render();
return;
}
}
} }
private void AddPileColumn(Transform parent, PlayerView player) private void AddPileColumn(Transform parent, PlayerView player)
@ -2054,6 +2098,19 @@ namespace Vanguard.UI
} }
} }
if (IsAbilityDamageTargeting())
{
var abilityHint = UiKit.CreateText(
_intents,
"AbilityDamageHint",
"Выберите закрытую карту урона для Counter Blast (необязательно).",
15,
new Color(0.72f, 0.80f, 0.94f),
TextAnchor.UpperLeft);
abilityHint.horizontalOverflow = HorizontalWrapMode.Wrap;
UiKit.SetPreferredHeight(abilityHint, 34f);
}
if (board.Count > 0) if (board.Count > 0)
{ {
var hint = UiKit.CreateText( var hint = UiKit.CreateText(

View File

@ -322,6 +322,12 @@ namespace Vanguard.UI
["Выберите одну триал-деку. 50 карт попадут в коллекцию, плюс 5 токенов на бустеры."] = "Choose one trial deck. 50 cards will be added to your collection, plus 5 tokens for boosters.", ["Выберите одну триал-деку. 50 карт попадут в коллекцию, плюс 5 токенов на бустеры."] = "Choose one trial deck. 50 cards will be added to your collection, plus 5 tokens for boosters.",
["Unity блокирует HTTP. Player Settings → Allow downloads over HTTP = Always, затем перезапусти Unity."] = "Unity blocks HTTP. Player Settings → Allow downloads over HTTP = Always, then restart Unity.", ["Unity блокирует HTTP. Player Settings → Allow downloads over HTTP = Always, затем перезапусти Unity."] = "Unity blocks HTTP. Player Settings → Allow downloads over HTTP = Always, then restart Unity.",
["VanguardApp не создан. Запусти игру из Boot или через Play в любой сцене клиента."] = "VanguardApp was not created. Start the game from Boot or via Play in any client scene.", ["VanguardApp не создан. Запусти игру из Boot или через Play в любой сцене клиента."] = "VanguardApp was not created. Start the game from Boot or via Play in any client scene.",
["Кликните карты, которые вернутся в колоду."] = "Click the cards you want to return to the deck.",
["К обмену выбрано карт: "] = "Cards selected for mulligan: ",
["Закрытая карта"] = "Face-down card",
["Выберите закрытую карту урона для Counter Blast (необязательно)."] = "Choose a face-down damage card for Counter Blast (optional).",
["Для Counter Blast выбрана карта урона "] = "Damage card selected for Counter Blast: ",
["УРОН · "] = "DAMAGE · ",
}; };
private static readonly KeyValuePair<string, string>[] Phrases = Exact private static readonly KeyValuePair<string, string>[] Phrases = Exact

View File

@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection;
using NUnit.Framework; using NUnit.Framework;
using UnityEngine; using UnityEngine;
using UnityEngine.EventSystems; using UnityEngine.EventSystems;
@ -8,8 +10,10 @@ using Vanguard.Api.Contracts;
using Vanguard.App; using Vanguard.App;
using Vanguard.Auth; using Vanguard.Auth;
using Vanguard.Meta; using Vanguard.Meta;
using Vanguard.Shared.Protocol;
using Vanguard.State; using Vanguard.State;
using Vanguard.UI; using Vanguard.UI;
using BattleView = Vanguard.UI.BattleView;
namespace Vanguard.Tests namespace Vanguard.Tests
{ {
@ -115,6 +119,100 @@ namespace Vanguard.Tests
Object.DestroyImmediate(go); Object.DestroyImmediate(go);
} }
[Test]
public void Battle_Renders_Damage_And_Rested_Cards()
{
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("Test Canvas");
var battle = new BattleView(canvas.transform, meta, () => { });
var screen = Resources.FindObjectsOfTypeAll<GameObject>()
.First(go => go.name == "BattleScreen");
screen.SetActive(true);
CardView Card(string id, int instance, bool rested = false) => new CardView
{
PrintingId = id,
InstanceId = instance,
Rested = rested,
FaceUp = true,
Power = 8000,
BasePower = 8000,
Critical = 1,
BaseCritical = 1,
};
var self = new PlayerView
{
Index = 0,
Name = "Me",
Vanguard = Card("BT01/001EN", 1),
RearGuards = new CardView?[5],
Hand = new List<CardView> { Card("BT01/042EN", 2) },
Soul = new List<CardView> { Card("BT01/003EN", 3) },
FaceDownDamage = 2,
FaceUpDamage = new List<CardView> { Card("BT01/056EN", 4) },
DeckCount = 40,
Drop = new List<CardView>(),
};
self.RearGuards[3] = Card("BT01/042EN", 5, rested: true);
var opponent = new PlayerView
{
Index = 1,
Name = "Opponent",
Vanguard = Card("BT01/001EN", 6),
RearGuards = new CardView?[5],
Hand = new List<CardView>(),
Soul = new List<CardView>(),
FaceDownDamage = 1,
FaceUpDamage = new List<CardView>(),
DeckCount = 40,
Drop = new List<CardView>(),
};
var match = new MatchView
{
MatchId = "render-test",
You = 0,
ActivePlayer = 0,
Phase = GamePhase.Main,
Players = new List<PlayerView> { self, opponent },
Clock = new ClockView(),
LegalIntents = new List<IntentPayload>(),
};
typeof(BattleView)
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(battle, match);
typeof(BattleView)
.GetMethod("Render", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(battle, null);
Assert.IsTrue(Exists("DamageZone"));
Assert.IsTrue(Exists("Damage_0"));
Assert.IsTrue(Exists("Damage_1"));
Assert.IsTrue(Exists("Damage_2"));
Assert.IsTrue(Exists("Card_R4"));
var hasSidewaysCard = Resources.FindObjectsOfTypeAll<GameObject>()
.Where(go => go.name == "Card_R4")
.Any(go =>
{
var angle = go.transform.localEulerAngles.z;
return Mathf.Abs(Mathf.DeltaAngle(angle, 90f)) < 1f
|| Mathf.Abs(Mathf.DeltaAngle(angle, -90f)) < 1f;
});
Assert.IsTrue(hasSidewaysCard, "Rested card was not turned sideways");
Cleanup();
}
private static bool Exists(string name) => private static bool Exists(string name) =>
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name); Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);

View File

@ -9,8 +9,10 @@
], ],
"excludePlatforms": [], "excludePlatforms": [],
"allowUnsafeCode": false, "allowUnsafeCode": false,
"overrideReferences": false, "overrideReferences": true,
"precompiledReferences": [], "precompiledReferences": [
"Vanguard.Shared.dll"
],
"autoReferenced": false, "autoReferenced": false,
"defineConstraints": [], "defineConstraints": [],
"versionDefines": [], "versionDefines": [],

View File

@ -68,8 +68,11 @@ http://localhost:5080
действия, выбор силы/эффекта триггера, журнал событий и подсказки; действия, выбор силы/эффекта триггера, журнал событий и подсказки;
event-анимации с очередью и скипом. event-анимации с очередью и скипом.
- Карты в бою и коллекции: реальные арты, рамка по клану, имя, ранг, - Карты в бою и коллекции: реальные арты, рамка по клану, имя, ранг,
триггер, сила/крит, REST и рубашка для скрытых карт; call/ride-въезд, триггер, сила/крит, REST поворотом на бок и рубашка для скрытых карт;
пульс атаки и призрак ухода в дроп. call/ride-въезд, пульс атаки и призрак ухода в дроп.
- Зона урона: видимые карты на боку; heal выбирает карту урона, Counter
Blast принимает выбранную закрытую карту. Маллиган — выбор карт в руке
с обменом и добиром до пяти.
- Реальные арты: `CardImageService` ищет `Resources/CardArt/{imageKey}`; - Реальные арты: `CardImageService` ищет `Resources/CardArt/{imageKey}`;
`imageKey` приходит из `/api/cards`. Скачаны все **124** арта; `imageKey` приходит из `/api/cards`. Скачаны все **124** арта;
импорт — `tools/import_card_art.py`. импорт — `tools/import_card_art.py`.
@ -134,5 +137,5 @@ Smoke сцен (входит в Play Mode, открывает Boot → Login, п
``` ```
Тесты не ходят в сеть: проверяются composition root, создание Canvas, Тесты не ходят в сеть: проверяются composition root, создание Canvas,
экранов, импорт арта и локализация (статические строки, динамика, экранов, импорт арта, локализация (статические строки, динамика,
live-переключение). Сейчас **3/3**. live-переключение) и рендер боевой доски с уроном/rest. Сейчас **4/4**.

View File

@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
| Проверка | Результат | | Проверка | Результат |
|---|---| |---|---|
| Тесты сервера | **177/177** (132 правила/контракты + 45 сервер) | | Тесты сервера | **180/180** (135 правил/контрактов + 45 сервер) |
| `validate` | 0 ошибок, 0 предупреждений | | `validate` | 0 ошибок, 0 предупреждений |
| `translations check` | 94/94 | | `translations check` | 94/94 |
| `stress 1000` | 1000/1000 партий, 0 ошибок | | `stress 1000` | 1000/1000 партий, 0 ошибок |
@ -72,7 +72,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
Ожидаемо: Ожидаемо:
``` ```
Пройден! : не пройдено 0, пройдено 132 (Vanguard.Rules.Tests) Пройден! : не пройдено 0, пройдено 135 (Vanguard.Rules.Tests)
Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests) Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
OK: ошибок 0, предупреждений 0 OK: ошибок 0, предупреждений 0
Переведено: 94/94 Переведено: 94/94
@ -790,3 +790,28 @@ Battle; Boot и общие сервисы остаются персистент
- Ограничения: звука пока нет; полноценные drive/damage-анимации карт и - Ограничения: звука пока нет; полноценные drive/damage-анимации карт и
стола — следующий блок; локализация сделана runtime-словарём, без стола — следующий блок; локализация сделана runtime-словарём, без
Unity Localization-таблиц (пакет остаётся подключён). Unity Localization-таблиц (пакет остаётся подключён).
## 2026-10-22 — боевые карты, маллиган, урон
- Боевые карты переделаны: арт растянут на всю карту, сверху слот/грейд,
снизу плашка с именем и P/C/триггером; в руке — компактный вариант.
`CardImageService` проверен тестом: `td01_005` реально загружается.
- Rest теперь визуально поворачивает карту на бок; удалён старый текстовый
маркер `REST`. Позванные rear-guard'ы входят стоящими (`Call`,
`CallFromSoul`, `CallFromDeck` исправлены на `Rested = false`).
- Зона урона: отдельная сетка видимых карт на боку, до 6 штук; закрытые
показываются рубашкой, открытые — артом. При выборе карты урона (heal или
Counter Blast) карты подсвечиваются и кликаются.
- Heal: сервер больше не забирает последнюю карту автоматически, а создаёт
`ChoiceTargetKind.Damage`; клиент выбирает карту кликом. Counter Blast
принимает `DamageIndex` в `ActivateAbilityIntent`/`ResolveAbilityIntent`
и использует выбранную закрытую карту (остаток суммы — авто).
- Маллиган: клиент выбирает карты в руке, подсвечивает их и отправляет
`MulliganIntent`; сервер возвращает выбранные в колоду, перемешивает и
добирает столько же. После обмена доступно «Закончить маллиган».
- Протокол `Vanguard.Shared` расширен (`DamageIndex`, `ChoiceTargetKind`),
DLL пересобран и обновлён в `client/Assets/Plugins/Vanguard.Shared`.
- Проверки: сервер **135** правил + **45** серверных; клиент EditMode
**4/4** (включая рендер доски с уроном и отдыхающей картой);
`SCENE_SMOKE success=True active=Login`; `validate` и
`translations check` — без ошибок.

View File

@ -19,7 +19,7 @@
матчмейкинг, WebSocket-матч, история, реплеи. матчмейкинг, WebSocket-матч, история, реплеи.
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo, - Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
in-process реестр соединений. in-process реестр соединений.
- Тесты: **132** правил + **45** серверных, CI зелёный. - Тесты: **135** правил + **45** серверных, CI зелёный.
- Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Стенд: `http://192.168.31.158:18080`, `/health` → ok.
- Документация: `docs/server-api.md`, `docs/protocol.md`, - Документация: `docs/server-api.md`, `docs/protocol.md`,
`docs/HANDOFF.md`. `docs/HANDOFF.md`.
@ -61,11 +61,17 @@
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер - Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
создаёт casual-матч против случайного бота (`POST /api/game/practice`), создаёт casual-матч против случайного бота (`POST /api/game/practice`),
без истории, MMR и наград. без истории, MMR и наград.
- Боевой экран: реальные арты карт, рамка по клану, имя, ранг, триггер, - Боевой экран: реальный арт на всю карту, рамка по клану, имя, ранг,
сила/крит, REST и рубашка; процедурные плашки остаются fallback. триггер, сила/крит и рубашка; fallback — процедурная плашка.
Rest поворачивает карту на бок.
- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона - Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
(+soul), справа колода и ниже дроп; у противника зеркально; между (+soul), справа колода и ниже дроп; у противника зеркально; между
полями — зона защиты со щитом и проверками. полями — зона защиты со щитом и проверками.
- Зона урона: видимые карты на боку (закрытые — рубашкой, открытые —
артом). Heal предлагает выбрать карту урона; Counter Blast принимает
выбранную закрытую карту из зоны урона.
- Маллиган: клик по картам в руке выбирает их, отдельная кнопка меняет
выбранные карты; новые карты добираются до пяти, колода перемешивается.
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх - Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации». стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют, Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют,
@ -75,7 +81,8 @@
`tools/import_card_art.py`. `tools/import_card_art.py`.
- Взаимодействие в бою: клик по картам/зонам, подсветка целей, - Взаимодействие в бою: клик по картам/зонам, подсветка целей,
группировка действий (карты → цели, глобальные кнопки отдельно), группировка действий (карты → цели, глобальные кнопки отдельно),
выбор силы/эффекта триггера, журнал последних событий. выбор силы/эффекта триггера, журнал последних событий. Позванные
rear-guard'ы входят в стоячем положении; rest — только после атаки/буста.
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция - Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`). открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween, - Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween,
@ -111,9 +118,9 @@ curl http://127.0.0.1:18080/health
-executeMethod Vanguard.Editor.SceneSmoke.Run -executeMethod Vanguard.Editor.SceneSmoke.Run
``` ```
Клиентские проверки: **3/3** EditMode (сборка всех экранов, импорт арта, Клиентские проверки: **4/4** EditMode (сборка всех экранов, импорт арта,
перевод статических/динамических строк и live-переключение языка), перевод статических/динамических строк, live-переключение языка, рендер
`SCENE_SMOKE success=True`. доски с уроном и отдыхающей картой), `SCENE_SMOKE success=True`.
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены `SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены: ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:

View File

@ -167,13 +167,15 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
| 10 | `PassGuardIntent` | — | | 10 | `PassGuardIntent` | — |
| 11 | `ResolveTriggerIntent` | `powerTarget`, `effectTarget` | | 11 | `ResolveTriggerIntent` | `powerTarget`, `effectTarget` |
| 12 | `SelectTriggerIntent` | `index` | | 12 | `SelectTriggerIntent` | `index` |
| 13 | `ResolveAbilityIntent` | `accept` | | 13 | `ResolveAbilityIntent` | `accept`, `damageIndex?` |
| 14 | `ChooseIntent` | `index` | | 14 | `ChooseIntent` | `index` |
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?` | | 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?` |
| 16 | `EndTurnIntent` | — | | 16 | `EndTurnIntent` | — |
| 17 | `ConcedeIntent` | — | | 17 | `ConcedeIntent` | — |
Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний. Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний.
`damageIndex` — индекс карты урона в порядке отображения (сначала закрытые,
затем открытые); `-1` — авто.
`GuardIntent.discardPrintingId` заполняется только для идеальной защиты: `GuardIntent.discardPrintingId` заполняется только для идеальной защиты:
без него Sentinel не отменяет атаку; с ним указывается карта того же клана. без него Sentinel не отменяет атаку; с ним указывается карта того же клана.

View File

@ -15,9 +15,12 @@ public sealed record InterceptCommand(int Slot) : GameCommand;
public sealed record PassGuardCommand : GameCommand; public sealed record PassGuardCommand : GameCommand;
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand; public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
public sealed record SelectTriggerCommand(int Index) : GameCommand; public sealed record SelectTriggerCommand(int Index) : GameCommand;
public sealed record ResolveAbilityCommand(bool Accept) : GameCommand; public sealed record ResolveAbilityCommand(bool Accept, int DamageIndex = -1) : GameCommand;
public sealed record ChooseCommand(int Index) : GameCommand; public sealed record ChooseCommand(int Index) : GameCommand;
public sealed record ActivateAbilityCommand(int InstanceId, string? DiscardPrintingId = null) : GameCommand; public sealed record ActivateAbilityCommand(
int InstanceId,
string? DiscardPrintingId = null,
int DamageIndex = -1) : GameCommand;
public sealed record EndTurnCommand : GameCommand; public sealed record EndTurnCommand : GameCommand;
public sealed record CommandResult(bool Accepted, string? Reason) public sealed record CommandResult(bool Accepted, string? Reason)

View File

@ -71,6 +71,7 @@ public sealed class GameEngine
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary> /// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
private int _resolvingDepth; private int _resolvingDepth;
private int _counterBlastDisplayIndex = -1;
/// <summary> /// <summary>
/// Сколько проверок делает атакующая карта. Эффект может изменить число; /// Сколько проверок делает атакующая карта. Эффект может изменить число;
@ -331,7 +332,7 @@ public sealed class GameEngine
} }
} }
private CommandResult Activate(int instanceId, string? discardPrintingId) private CommandResult Activate(int instanceId, string? discardPrintingId, int damageIndex)
{ {
if (State.Phase != Phase.Main) if (State.Phase != Phase.Main)
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе."); return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
@ -433,7 +434,16 @@ public sealed class GameEngine
_events.Add(new UnitRetiredEvent(player, slot)); _events.Add(new UnitRetiredEvent(player, slot));
} }
ability.Resolve(context); _counterBlastDisplayIndex = damageIndex;
try
{
ability.Resolve(context);
}
finally
{
_counterBlastDisplayIndex = -1;
}
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true)); _events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
return CommandResult.Ok(); return CommandResult.Ok();
@ -495,14 +505,23 @@ public sealed class GameEngine
return CommandResult.Ok(); return CommandResult.Ok();
} }
private CommandResult ResolvePendingAbility(bool accept) private CommandResult ResolvePendingAbility(bool accept, int damageIndex)
{ {
var ability = State.PendingAbility; var ability = State.PendingAbility;
if (ability is null || State.Step != Step.Ability) if (ability is null || State.Step != Step.Ability)
return CommandResult.Reject("Сейчас нет способности, ожидающей решения."); return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
State.PendingAbility = null; State.PendingAbility = null;
ResolveAbility(ability, accept); _counterBlastDisplayIndex = damageIndex;
try
{
ResolveAbility(ability, accept);
}
finally
{
_counterBlastDisplayIndex = -1;
}
return CommandResult.Ok(); return CommandResult.Ok();
} }
@ -664,10 +683,30 @@ public sealed class GameEngine
public void CounterBlast(int player, int amount) public void CounterBlast(int player, int amount)
{ {
var damage = State.Players[player].Damage;
var flipped = 0; var flipped = 0;
foreach (var card in State.Players[player].Damage)
// Клиент передаёт индекс в том же порядке, в котором рисует урон:
// сначала закрытые, затем открытые карты.
if (_counterBlastDisplayIndex >= 0)
{ {
if (flipped == amount) var order = DamageDisplayOrder(player);
if (_counterBlastDisplayIndex < order.Count)
{
var chosen = order[_counterBlastDisplayIndex].Card;
if (!chosen.FaceUp)
{
chosen.FaceUp = true;
flipped++;
}
}
_counterBlastDisplayIndex = -1;
}
foreach (var card in damage)
{
if (flipped >= amount)
break; break;
if (card.FaceUp) if (card.FaceUp)
@ -1680,9 +1719,9 @@ public sealed class GameEngine
PassGuardCommand => PassGuard(), PassGuardCommand => PassGuard(),
ResolveTriggerCommand c => ResolveTrigger(c), ResolveTriggerCommand c => ResolveTrigger(c),
SelectTriggerCommand c => SelectTrigger(c.Index), SelectTriggerCommand c => SelectTrigger(c.Index),
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept), ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex),
ChooseCommand c => Choose(c.Index), ChooseCommand c => Choose(c.Index),
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId), ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
EndTurnCommand => EndTurn(), EndTurnCommand => EndTurn(),
_ => CommandResult.Reject("Неизвестная команда."), _ => CommandResult.Reject("Неизвестная команда."),
}; };
@ -2387,23 +2426,22 @@ public sealed class GameEngine
return true; return true;
} }
private List<(CardInstance Card, int Index)> DamageDisplayOrder(int player) =>
State.Players[player].Damage
.Select((card, index) => (Card: card, Index: index))
.OrderBy(item => item.Card.FaceUp)
.ThenBy(item => item.Index)
.ToList();
private void AskDamageChoice(int player, string prompt, Action<int> onChosen) private void AskDamageChoice(int player, string prompt, Action<int> onChosen)
{ {
var damage = State.Players[player].Damage; var order = DamageDisplayOrder(player);
// Закрытый урон показываем первым, затем открытый: так клиент
// отрисовывает зону урона и может сопоставить клик с вариантом.
var order = damage
.Select((card, index) => (card, index))
.OrderBy(item => item.card.FaceUp)
.ThenBy(item => item.index)
.ToList();
var options = order var options = order
.Select((item, displayIndex) => new ChoiceOption( .Select((item, displayIndex) => new ChoiceOption(
displayIndex, displayIndex,
item.card.FaceUp item.Card.FaceUp
? State.Catalog.CardOf(item.card.PrintingId).NameEn ? State.Catalog.CardOf(item.Card.PrintingId).NameEn
: "Закрытая карта")) : "Закрытая карта"))
.ToList(); .ToList();
@ -2413,7 +2451,7 @@ public sealed class GameEngine
Prompt = prompt, Prompt = prompt,
Options = options, Options = options,
Kind = ChoiceTargetKind.Damage, Kind = ChoiceTargetKind.Damage,
Resolve = displayIndex => onChosen(order[displayIndex].index), Resolve = displayIndex => onChosen(order[displayIndex].Index),
}; };
State.Step = Step.Choice; State.Step = Step.Choice;
State.ActivePlayer = player; State.ActivePlayer = player;

View File

@ -26,9 +26,9 @@ public static class CommandCodec
PassGuardCommand => "passGuard", PassGuardCommand => "passGuard",
ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}", ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}",
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}", SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}", ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}{Separator}{c.DamageIndex}",
ChooseCommand c => $"choose{Separator}{c.Index}", ChooseCommand c => $"choose{Separator}{c.Index}",
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}", ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}{Separator}{c.DamageIndex}",
EndTurnCommand => "endTurn", EndTurnCommand => "endTurn",
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."), _ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
}; };
@ -56,9 +56,14 @@ public static class CommandCodec
"passGuard" => new PassGuardCommand(), "passGuard" => new PassGuardCommand(),
"resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])), "resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])),
"selectTrigger" => new SelectTriggerCommand(ParseInt(parts[1])), "selectTrigger" => new SelectTriggerCommand(ParseInt(parts[1])),
"resolveAbility" => new ResolveAbilityCommand(parts[1] == "1"), "resolveAbility" => new ResolveAbilityCommand(
parts[1] == "1",
parts.Length > 2 ? ParseInt(parts[2]) : -1),
"choose" => new ChooseCommand(ParseInt(parts[1])), "choose" => new ChooseCommand(ParseInt(parts[1])),
"activate" => new ActivateAbilityCommand(ParseInt(parts[1]), Nullable(parts[2])), "activate" => new ActivateAbilityCommand(
ParseInt(parts[1]),
Nullable(parts[2]),
parts.Length > 3 ? ParseInt(parts[3]) : -1),
"endTurn" => new EndTurnCommand(), "endTurn" => new EndTurnCommand(),
_ => throw new FormatException($"Неизвестная команда: {encoded}"), _ => throw new FormatException($"Неизвестная команда: {encoded}"),
}; };

View File

@ -37,12 +37,17 @@ public static class IntentMapping
EffectTarget = c.EffectTarget, EffectTarget = c.EffectTarget,
}, },
SelectTriggerCommand c => new SelectTriggerIntent { Index = c.Index }, SelectTriggerCommand c => new SelectTriggerIntent { Index = c.Index },
ResolveAbilityCommand c => new ResolveAbilityIntent { Accept = c.Accept }, ResolveAbilityCommand c => new ResolveAbilityIntent
{
Accept = c.Accept,
DamageIndex = c.DamageIndex,
},
ChooseCommand c => new ChooseIntent { Index = c.Index }, ChooseCommand c => new ChooseIntent { Index = c.Index },
ActivateAbilityCommand c => new ActivateAbilityIntent ActivateAbilityCommand c => new ActivateAbilityIntent
{ {
InstanceId = c.InstanceId, InstanceId = c.InstanceId,
DiscardPrintingId = c.DiscardPrintingId, DiscardPrintingId = c.DiscardPrintingId,
DamageIndex = c.DamageIndex,
}, },
EndTurnCommand => new EndTurnIntent(), EndTurnCommand => new EndTurnIntent(),
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."), _ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
@ -66,9 +71,9 @@ public static class IntentMapping
PassGuardIntent => new PassGuardCommand(), PassGuardIntent => new PassGuardCommand(),
ResolveTriggerIntent c => new ResolveTriggerCommand(c.PowerTarget, c.EffectTarget), ResolveTriggerIntent c => new ResolveTriggerCommand(c.PowerTarget, c.EffectTarget),
SelectTriggerIntent c => new SelectTriggerCommand(c.Index), SelectTriggerIntent c => new SelectTriggerCommand(c.Index),
ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept), ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept, c.DamageIndex),
ChooseIntent c => new ChooseCommand(c.Index), ChooseIntent c => new ChooseCommand(c.Index),
ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId), ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
EndTurnIntent => new EndTurnCommand(), EndTurnIntent => new EndTurnCommand(),
ConcedeIntent => null, ConcedeIntent => null,
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."), _ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."),

View File

@ -119,6 +119,9 @@ public sealed class SelectTriggerIntent : IntentPayload
public sealed class ResolveAbilityIntent : IntentPayload public sealed class ResolveAbilityIntent : IntentPayload
{ {
[Key(0)] public bool Accept { get; set; } [Key(0)] public bool Accept { get; set; }
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
[Key(1)] public int DamageIndex { get; set; } = -1;
} }
[MessagePackObject] [MessagePackObject]
@ -132,6 +135,9 @@ public sealed class ActivateAbilityIntent : IntentPayload
{ {
[Key(0)] public int InstanceId { get; set; } [Key(0)] public int InstanceId { get; set; }
[Key(1)] public string? DiscardPrintingId { get; set; } [Key(1)] public string? DiscardPrintingId { get; set; }
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
[Key(2)] public int DamageIndex { get; set; } = -1;
} }
[MessagePackObject] [MessagePackObject]

View File

@ -115,6 +115,23 @@ public class GameEngineTests
Assert.Contains(engine.Events, e => e is MulliganEvent { Player: 0, Count: 2 }); Assert.Contains(engine.Events, e => e is MulliganEvent { Player: 0, Count: 2 });
} }
[Fact]
public void CounterBlast_Uses_Selected_Damage_Card()
{
var engine = InMainPhase();
var conroe = MakeCard(Conroe);
engine.State.Players[0].RearGuards[0] = conroe;
GiveDamage(engine, 0, 2);
var first = engine.State.Players[0].Damage[0];
var second = engine.State.Players[0].Damage[1];
Assert.True(engine.Execute(new ActivateAbilityCommand(conroe.Id, null, 1)).Accepted);
Assert.False(first.FaceUp);
Assert.True(second.FaceUp);
}
[Fact] [Fact]
public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy() public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy()
{ {
@ -205,6 +222,7 @@ public class GameEngineTests
Assert.NotNull(engine.State.PendingTrigger); Assert.NotNull(engine.State.PendingTrigger);
var rearGuard = engine.State.Players[0].RearGuards[0]!; var rearGuard = engine.State.Players[0].RearGuards[0]!;
rearGuard.Rested = true;
Assert.True(rearGuard.Rested); Assert.True(rearGuard.Rested);
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted); Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
@ -246,7 +264,7 @@ public class GameEngineTests
StackDeck(engine.State.Players[0], Gallatin, Gallatin); StackDeck(engine.State.Players[0], Gallatin, Gallatin);
GiveDamage(engine, 1, 2); GiveDamage(engine, 1, 2);
GiveDamage(engine, 0, 1); GiveDamage(engine, 0, 1);
StackDeck(engine.State.Players[1], Elaine); engine.State.Players[1].Deck.Insert(0, MakeCard(Elaine));
var chosen = engine.State.Players[1].Damage[0]; var chosen = engine.State.Players[1].Damage[0];
@ -814,6 +832,7 @@ public class GameEngineTests
engine.Execute(new CallCommand(Lien, 0)); engine.Execute(new CallCommand(Lien, 0));
var lien = engine.State.Players[0].RearGuards[0]!; var lien = engine.State.Players[0].RearGuards[0]!;
Give(engine, 0, Marron); Give(engine, 0, Marron);
lien.Rested = true;
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted); Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
} }