Fix battle card art, mulligan, damage targeting and rest
CI / test (push) Successful in 1m49s
Details
CI / test (push) Successful in 1m49s
Details
This commit is contained in:
parent
f5b7f3e2d8
commit
c0c00ccc4c
|
|
@ -275,9 +275,13 @@ drive/damage-анимации и звук — следующие. Финальн
|
|||
|
||||
- [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева,
|
||||
колода/дроп справа, зеркально у противника, зона защиты между полями.
|
||||
- [x] Карты: реальный арт из `imageKey`, рамка клана, рубашка, имя, ранг,
|
||||
триггер, сила/крит, REST/скрытая карта; процедурный fallback.
|
||||
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»).
|
||||
- [x] Карты: реальный арт на всю карту, рамка клана, рубашка, имя, ранг,
|
||||
триггер, сила/крит; REST поворачивает карту на бок; процедурный fallback.
|
||||
- [x] Старт: маллиган с выбором карт в руке, возвратом в колоду,
|
||||
перемешиванием и добиром до пяти.
|
||||
- [x] Зона урона: видимые карты на боку; heal и Counter Blast выбирают
|
||||
карту урона кликом.
|
||||
- [x] Позванные rear-guard'ы входят стоящими; rest — после атаки/буста.
|
||||
- [x] Ввод: кнопки легальных интентов; клик по картам/зонам,
|
||||
подсветка целей, группировка действий, выбор силы/эффекта триггера
|
||||
и журнал боя.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
||||
| EF-миграции | готово (`InitialCreate`) |
|
||||
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
||||
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с реальными артами, анимациями и RU/EN |
|
||||
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с артами, маллиганом, зоной урона, анимациями и RU/EN |
|
||||
| Лаунчер | не начато |
|
||||
|
||||
## Требования
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -72,6 +72,7 @@ namespace Vanguard.UI
|
|||
private int _pendingBoosterSlot = -1;
|
||||
private int? _triggerPowerSlot;
|
||||
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
|
||||
private int _abilityDamageIndex = -1;
|
||||
private readonly Dictionary<string, RectTransform> _cardPanels =
|
||||
new Dictionary<string, RectTransform>(StringComparer.Ordinal);
|
||||
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
|
||||
|
|
@ -207,6 +208,7 @@ namespace Vanguard.UI
|
|||
_pendingIntents.Clear();
|
||||
_highlightKeys.Clear();
|
||||
_mulliganSelected.Clear();
|
||||
_abilityDamageIndex = -1;
|
||||
_pendingBoosterSlot = -1;
|
||||
_triggerPowerSlot = null;
|
||||
_showAllIntents = false;
|
||||
|
|
@ -963,6 +965,15 @@ namespace Vanguard.UI
|
|||
if (_sending || _connection == null || _cts == null || _view == null)
|
||||
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();
|
||||
_highlightKeys.Clear();
|
||||
_pendingBoosterSlot = -1;
|
||||
|
|
@ -1215,16 +1226,17 @@ namespace Vanguard.UI
|
|||
gridElement.preferredHeight = 176f;
|
||||
|
||||
var damageChoice = IsDamageChoice();
|
||||
var abilityTargeting = IsAbilityDamageTargeting();
|
||||
var displayIndex = 0;
|
||||
|
||||
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)
|
||||
{
|
||||
AddDamageCard(grid, displayIndex, card, true, damageChoice);
|
||||
AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting: false);
|
||||
displayIndex++;
|
||||
}
|
||||
}
|
||||
|
|
@ -1234,7 +1246,8 @@ namespace Vanguard.UI
|
|||
int displayIndex,
|
||||
CardView card,
|
||||
bool faceUp,
|
||||
bool damageChoice)
|
||||
bool damageChoice,
|
||||
bool abilityTargeting)
|
||||
{
|
||||
var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white);
|
||||
var rect = panel;
|
||||
|
|
@ -1267,19 +1280,23 @@ namespace Vanguard.UI
|
|||
image.color = Color.white;
|
||||
}
|
||||
|
||||
var canClick = damageChoice || abilityTargeting;
|
||||
var selected = abilityTargeting && _abilityDamageIndex == displayIndex;
|
||||
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)
|
||||
outline.effectColor = selected
|
||||
? new Color(0.45f, 0.95f, 0.62f)
|
||||
: canClick
|
||||
? new Color(0.98f, 0.82f, 0.42f)
|
||||
: new Color(0.42f, 0.18f, 0.18f);
|
||||
outline.effectDistance = canClick
|
||||
? new Vector2(selected ? 4f : 3f, selected ? -4f : -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;
|
||||
button.interactable = canClick;
|
||||
}
|
||||
|
||||
private bool IsDamageChoice() =>
|
||||
|
|
@ -1287,19 +1304,46 @@ namespace Vanguard.UI
|
|||
&& _view.Choice.Prompt != null
|
||||
&& _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)
|
||||
{
|
||||
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;
|
||||
|
||||
for (var i = 0; i < _view.Choice.Options.Count; i++)
|
||||
{
|
||||
if (_view.Choice.Options[i].Slot == displayIndex)
|
||||
{
|
||||
OnIntentClicked(new ChooseIntent { Index = i });
|
||||
return;
|
||||
}
|
||||
}
|
||||
_abilityDamageIndex = displayIndex;
|
||||
SetStatus(
|
||||
"Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".",
|
||||
new Color(0.45f, 0.95f, 0.62f));
|
||||
Render();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var hint = UiKit.CreateText(
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
["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.",
|
||||
["Кликните карты, которые вернутся в колоду."] = "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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
|
@ -8,8 +10,10 @@ using Vanguard.Api.Contracts;
|
|||
using Vanguard.App;
|
||||
using Vanguard.Auth;
|
||||
using Vanguard.Meta;
|
||||
using Vanguard.Shared.Protocol;
|
||||
using Vanguard.State;
|
||||
using Vanguard.UI;
|
||||
using BattleView = Vanguard.UI.BattleView;
|
||||
|
||||
namespace Vanguard.Tests
|
||||
{
|
||||
|
|
@ -115,6 +119,100 @@ namespace Vanguard.Tests
|
|||
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) =>
|
||||
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"Vanguard.Shared.dll"
|
||||
],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
|
|
|
|||
|
|
@ -68,8 +68,11 @@ http://localhost:5080
|
|||
действия, выбор силы/эффекта триггера, журнал событий и подсказки;
|
||||
event-анимации с очередью и скипом.
|
||||
- Карты в бою и коллекции: реальные арты, рамка по клану, имя, ранг,
|
||||
триггер, сила/крит, REST и рубашка для скрытых карт; call/ride-въезд,
|
||||
пульс атаки и призрак ухода в дроп.
|
||||
триггер, сила/крит, REST поворотом на бок и рубашка для скрытых карт;
|
||||
call/ride-въезд, пульс атаки и призрак ухода в дроп.
|
||||
- Зона урона: видимые карты на боку; heal выбирает карту урона, Counter
|
||||
Blast принимает выбранную закрытую карту. Маллиган — выбор карт в руке
|
||||
с обменом и добиром до пяти.
|
||||
- Реальные арты: `CardImageService` ищет `Resources/CardArt/{imageKey}`;
|
||||
`imageKey` приходит из `/api/cards`. Скачаны все **124** арта;
|
||||
импорт — `tools/import_card_art.py`.
|
||||
|
|
@ -134,5 +137,5 @@ Smoke сцен (входит в Play Mode, открывает Boot → Login, п
|
|||
```
|
||||
|
||||
Тесты не ходят в сеть: проверяются composition root, создание Canvas,
|
||||
экранов, импорт арта и локализация (статические строки, динамика,
|
||||
live-переключение). Сейчас **3/3**.
|
||||
экранов, импорт арта, локализация (статические строки, динамика,
|
||||
live-переключение) и рендер боевой доски с уроном/rest. Сейчас **4/4**.
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
|
|||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
| Тесты сервера | **177/177** (132 правила/контракты + 45 сервер) |
|
||||
| Тесты сервера | **180/180** (135 правил/контрактов + 45 сервер) |
|
||||
| `validate` | 0 ошибок, 0 предупреждений |
|
||||
| `translations check` | 94/94 |
|
||||
| `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)
|
||||
OK: ошибок 0, предупреждений 0
|
||||
Переведено: 94/94
|
||||
|
|
@ -790,3 +790,28 @@ Battle; Boot и общие сервисы остаются персистент
|
|||
- Ограничения: звука пока нет; полноценные drive/damage-анимации карт и
|
||||
стола — следующий блок; локализация сделана runtime-словарём, без
|
||||
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` — без ошибок.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
матчмейкинг, WebSocket-матч, история, реплеи.
|
||||
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
|
||||
in-process реестр соединений.
|
||||
- Тесты: **132** правил + **45** серверных, CI зелёный.
|
||||
- Тесты: **135** правил + **45** серверных, CI зелёный.
|
||||
- Стенд: `http://192.168.31.158:18080`, `/health` → ok.
|
||||
- Документация: `docs/server-api.md`, `docs/protocol.md`,
|
||||
`docs/HANDOFF.md`.
|
||||
|
|
@ -61,11 +61,17 @@
|
|||
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
|
||||
создаёт casual-матч против случайного бота (`POST /api/game/practice`),
|
||||
без истории, MMR и наград.
|
||||
- Боевой экран: реальные арты карт, рамка по клану, имя, ранг, триггер,
|
||||
сила/крит, REST и рубашка; процедурные плашки остаются fallback.
|
||||
- Боевой экран: реальный арт на всю карту, рамка по клану, имя, ранг,
|
||||
триггер, сила/крит и рубашка; fallback — процедурная плашка.
|
||||
Rest поворачивает карту на бок.
|
||||
- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
|
||||
(+soul), справа колода и ниже дроп; у противника зеркально; между
|
||||
полями — зона защиты со щитом и проверками.
|
||||
- Зона урона: видимые карты на боку (закрытые — рубашкой, открытые —
|
||||
артом). Heal предлагает выбрать карту урона; Counter Blast принимает
|
||||
выбранную закрытую карту из зоны урона.
|
||||
- Маллиган: клик по картам в руке выбирает их, отдельная кнопка меняет
|
||||
выбранные карты; новые карты добираются до пяти, колода перемешивается.
|
||||
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
|
||||
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
|
||||
Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют,
|
||||
|
|
@ -75,7 +81,8 @@
|
|||
`tools/import_card_art.py`.
|
||||
- Взаимодействие в бою: клик по картам/зонам, подсветка целей,
|
||||
группировка действий (карты → цели, глобальные кнопки отдельно),
|
||||
выбор силы/эффекта триггера, журнал последних событий.
|
||||
выбор силы/эффекта триггера, журнал последних событий. Позванные
|
||||
rear-guard'ы входят в стоячем положении; rest — только после атаки/буста.
|
||||
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
|
||||
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
|
||||
- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween,
|
||||
|
|
@ -111,9 +118,9 @@ curl http://127.0.0.1:18080/health
|
|||
-executeMethod Vanguard.Editor.SceneSmoke.Run
|
||||
```
|
||||
|
||||
Клиентские проверки: **3/3** EditMode (сборка всех экранов, импорт арта,
|
||||
перевод статических/динамических строк и live-переключение языка),
|
||||
`SCENE_SMOKE success=True`.
|
||||
Клиентские проверки: **4/4** EditMode (сборка всех экранов, импорт арта,
|
||||
перевод статических/динамических строк, live-переключение языка, рендер
|
||||
доски с уроном и отдыхающей картой), `SCENE_SMOKE success=True`.
|
||||
|
||||
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
|
||||
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:
|
||||
|
|
|
|||
|
|
@ -167,13 +167,15 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
|||
| 10 | `PassGuardIntent` | — |
|
||||
| 11 | `ResolveTriggerIntent` | `powerTarget`, `effectTarget` |
|
||||
| 12 | `SelectTriggerIntent` | `index` |
|
||||
| 13 | `ResolveAbilityIntent` | `accept` |
|
||||
| 13 | `ResolveAbilityIntent` | `accept`, `damageIndex?` |
|
||||
| 14 | `ChooseIntent` | `index` |
|
||||
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?` |
|
||||
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?` |
|
||||
| 16 | `EndTurnIntent` | — |
|
||||
| 17 | `ConcedeIntent` | — |
|
||||
|
||||
Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний.
|
||||
`damageIndex` — индекс карты урона в порядке отображения (сначала закрытые,
|
||||
затем открытые); `-1` — авто.
|
||||
`GuardIntent.discardPrintingId` заполняется только для идеальной защиты:
|
||||
без него Sentinel не отменяет атаку; с ним указывается карта того же клана.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ public sealed record InterceptCommand(int Slot) : GameCommand;
|
|||
public sealed record PassGuardCommand : GameCommand;
|
||||
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : 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 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 CommandResult(bool Accepted, string? Reason)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ public sealed class GameEngine
|
|||
|
||||
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
|
||||
private int _resolvingDepth;
|
||||
private int _counterBlastDisplayIndex = -1;
|
||||
|
||||
/// <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)
|
||||
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
|
||||
|
|
@ -433,7 +434,16 @@ public sealed class GameEngine
|
|||
_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));
|
||||
|
||||
return CommandResult.Ok();
|
||||
|
|
@ -495,14 +505,23 @@ public sealed class GameEngine
|
|||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
private CommandResult ResolvePendingAbility(bool accept)
|
||||
private CommandResult ResolvePendingAbility(bool accept, int damageIndex)
|
||||
{
|
||||
var ability = State.PendingAbility;
|
||||
if (ability is null || State.Step != Step.Ability)
|
||||
return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
|
||||
|
||||
State.PendingAbility = null;
|
||||
ResolveAbility(ability, accept);
|
||||
_counterBlastDisplayIndex = damageIndex;
|
||||
try
|
||||
{
|
||||
ResolveAbility(ability, accept);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_counterBlastDisplayIndex = -1;
|
||||
}
|
||||
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
|
||||
|
|
@ -664,10 +683,30 @@ public sealed class GameEngine
|
|||
|
||||
public void CounterBlast(int player, int amount)
|
||||
{
|
||||
var damage = State.Players[player].Damage;
|
||||
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;
|
||||
|
||||
if (card.FaceUp)
|
||||
|
|
@ -1680,9 +1719,9 @@ public sealed class GameEngine
|
|||
PassGuardCommand => PassGuard(),
|
||||
ResolveTriggerCommand c => ResolveTrigger(c),
|
||||
SelectTriggerCommand c => SelectTrigger(c.Index),
|
||||
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
|
||||
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex),
|
||||
ChooseCommand c => Choose(c.Index),
|
||||
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
|
||||
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
|
||||
EndTurnCommand => EndTurn(),
|
||||
_ => CommandResult.Reject("Неизвестная команда."),
|
||||
};
|
||||
|
|
@ -2387,23 +2426,22 @@ public sealed class GameEngine
|
|||
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)
|
||||
{
|
||||
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 order = DamageDisplayOrder(player);
|
||||
|
||||
var options = order
|
||||
.Select((item, displayIndex) => new ChoiceOption(
|
||||
displayIndex,
|
||||
item.card.FaceUp
|
||||
? State.Catalog.CardOf(item.card.PrintingId).NameEn
|
||||
item.Card.FaceUp
|
||||
? State.Catalog.CardOf(item.Card.PrintingId).NameEn
|
||||
: "Закрытая карта"))
|
||||
.ToList();
|
||||
|
||||
|
|
@ -2413,7 +2451,7 @@ public sealed class GameEngine
|
|||
Prompt = prompt,
|
||||
Options = options,
|
||||
Kind = ChoiceTargetKind.Damage,
|
||||
Resolve = displayIndex => onChosen(order[displayIndex].index),
|
||||
Resolve = displayIndex => onChosen(order[displayIndex].Index),
|
||||
};
|
||||
State.Step = Step.Choice;
|
||||
State.ActivePlayer = player;
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ public static class CommandCodec
|
|||
PassGuardCommand => "passGuard",
|
||||
ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}",
|
||||
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}",
|
||||
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}",
|
||||
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}{Separator}{c.DamageIndex}",
|
||||
EndTurnCommand => "endTurn",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
||||
};
|
||||
|
|
@ -56,9 +56,14 @@ public static class CommandCodec
|
|||
"passGuard" => new PassGuardCommand(),
|
||||
"resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])),
|
||||
"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])),
|
||||
"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(),
|
||||
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,12 +37,17 @@ public static class IntentMapping
|
|||
EffectTarget = c.EffectTarget,
|
||||
},
|
||||
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 },
|
||||
ActivateAbilityCommand c => new ActivateAbilityIntent
|
||||
{
|
||||
InstanceId = c.InstanceId,
|
||||
DiscardPrintingId = c.DiscardPrintingId,
|
||||
DamageIndex = c.DamageIndex,
|
||||
},
|
||||
EndTurnCommand => new EndTurnIntent(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
||||
|
|
@ -66,9 +71,9 @@ public static class IntentMapping
|
|||
PassGuardIntent => new PassGuardCommand(),
|
||||
ResolveTriggerIntent c => new ResolveTriggerCommand(c.PowerTarget, c.EffectTarget),
|
||||
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),
|
||||
ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId),
|
||||
ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
|
||||
EndTurnIntent => new EndTurnCommand(),
|
||||
ConcedeIntent => null,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."),
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ public sealed class SelectTriggerIntent : IntentPayload
|
|||
public sealed class ResolveAbilityIntent : IntentPayload
|
||||
{
|
||||
[Key(0)] public bool Accept { get; set; }
|
||||
|
||||
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
|
||||
[Key(1)] public int DamageIndex { get; set; } = -1;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
|
@ -132,6 +135,9 @@ public sealed class ActivateAbilityIntent : IntentPayload
|
|||
{
|
||||
[Key(0)] public int InstanceId { get; set; }
|
||||
[Key(1)] public string? DiscardPrintingId { get; set; }
|
||||
|
||||
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
|
||||
[Key(2)] public int DamageIndex { get; set; } = -1;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
|
|
|
|||
|
|
@ -115,6 +115,23 @@ public class GameEngineTests
|
|||
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]
|
||||
public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy()
|
||||
{
|
||||
|
|
@ -205,6 +222,7 @@ public class GameEngineTests
|
|||
Assert.NotNull(engine.State.PendingTrigger);
|
||||
|
||||
var rearGuard = engine.State.Players[0].RearGuards[0]!;
|
||||
rearGuard.Rested = true;
|
||||
Assert.True(rearGuard.Rested);
|
||||
|
||||
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
|
||||
|
|
@ -246,7 +264,7 @@ public class GameEngineTests
|
|||
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
|
||||
GiveDamage(engine, 1, 2);
|
||||
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];
|
||||
|
||||
|
|
@ -814,6 +832,7 @@ public class GameEngineTests
|
|||
engine.Execute(new CallCommand(Lien, 0));
|
||||
var lien = engine.State.Players[0].RearGuards[0]!;
|
||||
Give(engine, 0, Marron);
|
||||
lien.Rested = true;
|
||||
|
||||
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue