From 30aed3b25d0eaf71c286ec4981a27f8a6da69682 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Fri, 23 Oct 2026 16:11:55 +0500 Subject: [PATCH] Fix first-turn battle, same-grade ride, ability and guard choices, center camera --- .../Vanguard/Runtime/App/SceneEnvironment.cs | 4 +- .../Board3D/Config/PlaymatLayoutConfig.asset | 4 +- .../Board3D/Config/PlaymatLayoutConfig.cs | 4 +- .../Assets/Vanguard/Runtime/UI/BattleView.cs | 220 +++++++++++++++++- .../Vanguard/Runtime/UI/Localization.cs | 8 + .../Tests/Editor/PlaymatLayoutTests.cs | 11 + docs/HANDOFF.md | 27 ++- docs/SESSION-STATE.md | 18 +- docs/game-rules.md | 3 +- .../src/Vanguard.Rules/Engine/GameEngine.cs | 7 +- .../Vanguard.Rules.Tests/GameEngineTests.cs | 37 +++ 11 files changed, 317 insertions(+), 26 deletions(-) diff --git a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs index 86cfdc2..23aff9e 100644 --- a/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs +++ b/client/Assets/Vanguard/Runtime/App/SceneEnvironment.cs @@ -54,12 +54,12 @@ namespace Vanguard.App camera.fieldOfView = theme == SceneTheme.Battle ? 46f : 52f; camera.transform.position = theme switch { - SceneTheme.Battle => new Vector3(1.4f, 5.8f, -8f), + SceneTheme.Battle => new Vector3(0f, 4.2f, -8f), SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f), _ => new Vector3(0f, 3.2f, -10f), }; camera.transform.rotation = theme == SceneTheme.Battle - ? Quaternion.Euler(35.9f, 0f, 0f) + ? Quaternion.Euler(27.7f, 0f, 0f) : Quaternion.Euler(8f, 0f, 0f); } diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset index 63e2954..d8acfad 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.asset @@ -56,8 +56,8 @@ MonoBehaviour: damageX: 3 pileX: 2.95 showZoneLabels: 0 - cameraPosition: {x: 1.4, y: 5.8, z: -8} - cameraTarget: {x: 1.4, y: 0, z: 0} + cameraPosition: {x: 0, y: 4.2, z: -8} + cameraTarget: {x: 0, y: 0, z: 0} cameraFov: 46 tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1} matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1} diff --git a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs index dd727d6..4d632ef 100644 --- a/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs +++ b/client/Assets/Vanguard/Runtime/Board3D/Config/PlaymatLayoutConfig.cs @@ -87,8 +87,8 @@ namespace Vanguard.Board3D [Header("Камера")] [Tooltip("Фиксированная камера над полем. Не двигается игроком.")] - public Vector3 cameraPosition = new Vector3(1.4f, 5.8f, -8.0f); - public Vector3 cameraTarget = new Vector3(1.4f, 0f, 0f); + public Vector3 cameraPosition = new Vector3(0f, 4.2f, -8f); + public Vector3 cameraTarget = new Vector3(0f, 0f, 0f); public float cameraFov = 46f; [Header("Палитра")] diff --git a/client/Assets/Vanguard/Runtime/UI/BattleView.cs b/client/Assets/Vanguard/Runtime/UI/BattleView.cs index aa06809..f1984aa 100644 --- a/client/Assets/Vanguard/Runtime/UI/BattleView.cs +++ b/client/Assets/Vanguard/Runtime/UI/BattleView.cs @@ -1086,8 +1086,7 @@ namespace Vanguard.UI } private void OpenCardMenu(BoardCardRef card) - { - if (_view == null || _board3D == null) + { if (_view == null || _board3D == null) return; var target = new BoardTarget( @@ -1127,9 +1126,17 @@ namespace Vanguard.UI })); } + var abilities = sources.OfType().ToList(); + if (abilities.Count > 0) + { + actions.Add(new CardActionMenuItem( + "Активировать способность", + () => OpenAbilitySelector(abilities))); + } + foreach (var intent in sources) { - if (intent is AttackIntent or MoveIntent) + if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent) continue; var captured = intent; @@ -1225,6 +1232,159 @@ namespace Vanguard.UI OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false)); } + private void AddAbilityButton(IReadOnlyList abilities) + { + if (abilities == null || abilities.Count == 0) + return; + + var button = UiKit.CreateButton( + _intents, + "ActivateAbility", + "Активировать способность", + () => OpenAbilitySelector(abilities), + 16); + button.interactable = !_inputLock.IsLocked; + UiKit.SetPreferredHeight(button, 46f); + } + + private void OpenAbilitySelector(IReadOnlyList abilities) + { + var groups = abilities + .GroupBy(ability => ability.InstanceId) + .ToList(); + if (groups.Count == 0) + return; + + if (groups.Count == 1) + { + ShowAbilityVariantChoice(groups[0].ToList()); + return; + } + + var items = groups + .Select(group => new CardActionMenuItem( + "Активировать: " + CardNameForInstance(group.Key), + () => ShowAbilityVariantChoice(group.ToList()))) + .ToList(); + ShowChoiceMenu("Способности", items); + } + + private void ShowAbilityVariantChoice( + IReadOnlyList variants) + { + var distinct = variants + .GroupBy(variant => variant.DiscardPrintingId ?? string.Empty) + .Select(group => group.First()) + .ToList(); + if (distinct.Count == 0) + return; + + if (distinct.Count == 1) + { + OnIntentClicked(distinct[0]); + return; + } + + var items = distinct + .Select(variant => new CardActionMenuItem( + string.IsNullOrEmpty(variant.DiscardPrintingId) + ? "Без сброса" + : "Сбросить: " + CardName(variant.DiscardPrintingId), + () => OnIntentClicked(variant))) + .ToList(); + ShowChoiceMenu("Сброс для способности", items); + } + + private void ShowGuardDiscardChoice( + IReadOnlyList guards, + Action onClosed = null) + { + var distinct = guards + .GroupBy(guard => guard.DiscardPrintingId ?? string.Empty) + .Select(group => group.First()) + .ToList(); + if (distinct.Count == 0) + return; + + if (distinct.Count > 1) + { + distinct = distinct + .Where(guard => !string.IsNullOrEmpty(guard.DiscardPrintingId)) + .ToList(); + } + + if (distinct.Count == 1) + { + OnIntentClicked(distinct[0]); + return; + } + + var items = distinct + .Select(guard => new CardActionMenuItem( + string.IsNullOrEmpty(guard.DiscardPrintingId) + ? "Guard без сброса" + : "Perfect Guard: сбросить " + CardName(guard.DiscardPrintingId), + () => OnIntentClicked(guard))) + .ToList(); + ShowChoiceMenu("Guard", items, onClosed); + } + + private void ShowChoiceMenu( + string title, + IReadOnlyList items, + Action onClosed = null) + { + if (_cardMenu == null || items == null || items.Count == 0) + return; + + _inputLock.Acquire("card-menu"); + _cardMenu.Show( + new Vector2(Screen.width * 0.5f, Screen.height * 0.5f), + title, + items, + () => + { + _inputLock.Release("card-menu"); + onClosed?.Invoke(); + }); + } + + private string CardNameForInstance(int instanceId) + { + if (_view == null) + return "карта #" + instanceId; + + foreach (var player in _view.Players) + { + var cards = new List(); + if (player.Vanguard != null) + cards.Add(player.Vanguard); + if (player.RearGuards != null) + { + foreach (var card in player.RearGuards) + { + if (card != null) + cards.Add(card); + } + } + + if (player.Hand != null) + cards.AddRange(player.Hand); + if (player.Drop != null) + cards.AddRange(player.Drop); + if (player.Soul != null) + cards.AddRange(player.Soul); + if (player.Guardian != null) + cards.AddRange(player.Guardian); + + var found = cards.FirstOrDefault(card => card.InstanceId == instanceId); + if (found != null) + return CardName(found.PrintingId); + } + + return "карта #" + instanceId; + } + private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target) { if (_view == null || _sending || _inputLock.IsLocked) @@ -1237,8 +1397,16 @@ namespace Vanguard.UI { if (target.Zone == BoardZone.Guardian) { - intent = legal.OfType() - .FirstOrDefault(item => item.PrintingId == source.PrintingId); + var guards = legal.OfType() + .Where(item => item.PrintingId == source.PrintingId) + .ToList(); + if (guards.Count > 1) + { + ShowGuardDiscardChoice(guards, Render); + return; + } + + intent = guards.FirstOrDefault(); } else if (target.Player == _view.You && target.Zone == BoardZone.Vanguard) { @@ -1354,6 +1522,7 @@ namespace Vanguard.UI var intents = (view.LegalIntents ?? new List()) .Where(IsPhaseIntent) + .Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1)) .ToList(); if (intents.Count == 0) { @@ -1696,6 +1865,13 @@ namespace Vanguard.UI .Where(intent => TryGetSource(intent, out var source) && source.Matches(target)) .ToList(); + var guards = sources.OfType().ToList(); + if (guards.Count > 0 && guards.Count == sources.Count) + { + ShowGuardDiscardChoice(guards); + return; + } + if (sources.Count == 0) { SetStatus("Для этой карты сейчас нет доступного действия.", Muted); @@ -1713,6 +1889,28 @@ namespace Vanguard.UI .Where(intent => TryGetTarget(intent, out _)) .ToList(); + if (withTargets.Count == 0 && sources.Count > 1) + { + var abilities = sources.OfType().ToList(); + var other = sources + .Where(intent => intent is not ActivateAbilityIntent) + .ToList(); + var items = other + .Select(intent => new CardActionMenuItem( + Label(intent), + () => OnIntentClicked(intent))) + .ToList(); + if (abilities.Count > 0) + { + items.Add(new CardActionMenuItem( + "Активировать способность", + () => OpenAbilitySelector(abilities))); + } + + ShowChoiceMenu("Действия", items); + return; + } + if (withTargets.Count == 0) { ClearSelection(rebuild: false); @@ -1956,9 +2154,14 @@ namespace Vanguard.UI var legal = view.LegalIntents ?? new List(); var playable = legal.Where(intent => intent is not ConcedeIntent).ToList(); - var board = playable.Where(IsBoardIntent).ToList(); + var abilities = playable.OfType().ToList(); + var board = playable + .Where(intent => IsBoardIntent(intent) && intent is not ActivateAbilityIntent) + .ToList(); var global = playable - .Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent)) + .Where(intent => !IsBoardIntent(intent) + && !IsPhaseIntent(intent) + && intent is not ActivateAbilityIntent) .ToList(); if (_pendingIntents.Count > 0) @@ -2065,6 +2268,9 @@ namespace Vanguard.UI UiKit.SetPreferredHeight(toggle, 38f); } + if (abilities.Count > 0) + AddAbilityButton(abilities); + foreach (var intent in global) AddIntentButton(intent); diff --git a/client/Assets/Vanguard/Runtime/UI/Localization.cs b/client/Assets/Vanguard/Runtime/UI/Localization.cs index b713a98..a40a459 100644 --- a/client/Assets/Vanguard/Runtime/UI/Localization.cs +++ b/client/Assets/Vanguard/Runtime/UI/Localization.cs @@ -63,6 +63,14 @@ namespace Vanguard.UI ["Атака "] = "Attack ", ["Атака"] = "Attack", ["Атака с поддержкой: "] = "Boosted attack: ", + ["Активировать способность"] = "Activate ability", + ["Активировать: "] = "Activate: ", + ["Сброс для способности"] = "Discard for ability", + ["Сбросить: "] = "Discard: ", + ["Без сброса"] = "No discard", + ["Guard без сброса"] = "Guard without discard", + ["Perfect Guard: сбросить "] = "Perfect Guard: discard ", + ["Действия"] = "Actions", ["Переместить"] = "Move", ["Подробнее"] = "Details", ["Нет доступных действий"] = "No available actions", diff --git a/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs b/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs index fae0845..37ce33f 100644 --- a/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs +++ b/client/Assets/Vanguard/Tests/Editor/PlaymatLayoutTests.cs @@ -7,6 +7,17 @@ namespace Vanguard.Tests { public sealed class PlaymatLayoutTests { + [Test] + public void Camera_Is_Centered_And_Only_Pitches_Down() + { + var config = PlaymatLayoutConfig.CreateDefault(); + Assert.AreEqual(0f, config.cameraPosition.x, 0.0001f); + Assert.AreEqual(0f, config.cameraTarget.x, 0.0001f); + Assert.AreEqual(config.cameraPosition.x, config.cameraTarget.x, 0.0001f); + Assert.Greater(config.cameraPosition.y, config.cameraTarget.y); + Assert.AreEqual(0f, config.cameraTarget.z, 0.0001f); + } + [Test] public void Default_Playmat_Has_Six_Circles_Per_Side() { diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 9469d36..436c042 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты | Проверка | Результат | |---|---| -| Тесты сервера | **184/184** (139 правил/контрактов + 45 сервер) | +| Тесты сервера | **186/186** (141 правил/контрактов + 45 сервер) | | `validate` | 0 ошибок, 0 предупреждений | | `translations check` | 94/94 | | `stress 1000` | 1000/1000 партий, 0 ошибок | @@ -858,8 +858,9 @@ Battle; Boot и общие сервисы остаются персистент - `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики, drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и индикатором точки броска. Ошибка интента возвращает карту на место. -- Камера фиксирована над полем: позиция `(1.4, 5.8, -8)`, цель - `(1.4, 0, 0)`, FOV 46; игрок её не двигает. Рука не лежит на столе, +- Камера фиксирована по центру поля: позиция `(0, 4.2, -8)`, цель + `(0, 0, 0)`, FOV 46; наклон только по X, без бокового смещения, игрок её + не двигает. Рука не лежит на столе, а закреплена в экране снизу по центру (`handViewportX/Y`, `handViewDistance`) и всегда развёрнута лицом к игроку; уменьшена, чтобы не перекрывать задний ряд RC. Драг из руки уходит на стол. @@ -958,7 +959,7 @@ Battle; Boot и общие сервисы остаются персистент - Добавлен `Battle_Layout_Fits_Reference_Resolution`: проверяет блоки зон, панель фаз и боевую плашку на 1920×1080 и отсутствие пересечений; при другом разрешении CanvasScaler масштабирует reference-раскладку. -- Проверки: клиент EditMode **28/28**, `SCENE_SMOKE success=True`, +- Проверки: клиент EditMode **29/29**, `SCENE_SMOKE success=True`, `PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`. - Дальше: только 3D-модели/наполнение сцен, анимации retire/ghost и декбилдер; prefab-оверлеи и раскладка закрыты. @@ -978,3 +979,21 @@ Battle; Boot и общие сервисы остаются персистент не показателен: runtime-материалы требуют графического устройства. - Сервер для standalone по умолчанию — `http://192.168.31.158:18080`; его нужно поднять/держать доступным. + +## 2026-10-23 — Исправления боя и камера + +- Первый ход: `EnterBattleCommand` больше не попадает в легальные интенты, + пока действует правило первого хода; кнопка «В бой» не появляется. + Атака по-прежнему запрещена. +- Смена: в фазе Ride предлагается и карта того же ранга, и следующего + (раньше legal-команды содержали только `+1`, хотя движок разрешал same + grade). +- Способности: `ActivateAbilityIntent` группируются по карте; в UI одна + кнопка «Активировать способность». Если целей/вариантов несколько — + отдельный overlay, затем выбор карты для сброса (без duplicate-кнопок). +- Guard: варианты Perfect Guard/сброса показываются отдельным overlay; + случайный первый интент больше не выбирается. +- Камера боя: `(0, 4.2, -8)` → цель `(0, 0, 0)`, только наклон по X; + поле по центру, без бокового смещения. +- Проверки: сервер **141** правил + **45** серверных, `validate` и + `translations check` OK; клиент EditMode **29/29**. diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md index fc6d032..828f814 100644 --- a/docs/SESSION-STATE.md +++ b/docs/SESSION-STATE.md @@ -19,7 +19,7 @@ матчмейкинг, WebSocket-матч, история, реплеи. - Orleans включается `Orleans:Enabled`; в compose включён. Один silo, in-process реестр соединений. -- Тесты: **139** правил + **45** серверных, CI зелёный. +- Тесты: **141** правил + **45** серверных, CI зелёный. - Стенд: `http://192.168.31.158:18080`, `/health` → ok. - Документация: `docs/server-api.md`, `docs/protocol.md`, `docs/game-rules.md`, `docs/HANDOFF.md`. @@ -68,10 +68,10 @@ Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`), общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру и всегда смотрит на игрока, а не лежит на столе; уменьшена, чтобы не - закрывать задний ряд RC. Камера фиксирована над полем под небольшим - углом и не двигается игроком: `(1.4, 5.8, -8)`, цель `(1.4, 0, 0)`, - FOV 46. Драг-н-дроп на плоскость стола с подсветкой целей и плавным - снапом. + закрывать задний ряд RC. Камера фиксирована по центру поля под небольшим + углом к горизонту и не двигается игроком: `(0, 4.2, -8)`, цель + `(0, 0, 0)`, FOV 46; наклон только по X, без бокового смещения. Драг-н-дроп + на плоскость стола с подсветкой целей и плавным снапом. - Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у противника зеркально; между полями общий круг защиты; колода, дроп, триггер-зона и урон — 3D-зоны. Локальный игрок всегда снизу, даже если @@ -79,6 +79,12 @@ лицом вверх; урон — один вертикальный ряд, карта входит открытой. Над колодой — зона проверки триггера. Под картами V/R — динамические `P/C`. +- Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом + ходу переход в Battle не предлагается; атака по-прежнему запрещена. +- Способности: кнопка «Активировать способность» одна на карту; при + нескольких вариантах открывается overlay выбора, затем — выбор карты + для сброса. Perfect Guard также спрашивает карту для сброса отдельным + overlay, а не выбирает случайную. - Статистика карт — отдельное UI-наложение: своя панель снизу слева, противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер` кликабельны и открывают общий горизонтальный `CardListView`; из списка @@ -175,7 +181,7 @@ Standalone Windows: -executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat ``` -Клиентские проверки: **28/28** EditMode (сборка всех экранов, импорт арта, +Клиентские проверки: **29/29** EditMode (сборка всех экранов, импорт арта, перевод статических/динамических строк, live-переключение языка, рендер 3D-доски с уроном и отдыхающей картой, геометрия поля, правила перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона, diff --git a/docs/game-rules.md b/docs/game-rules.md index f71dbed..b90581f 100644 --- a/docs/game-rules.md +++ b/docs/game-rules.md @@ -96,7 +96,8 @@ 1. Каждый игрок выбирает карту 0 ранга и кладёт её **закрытой** в круг авангарда — это стартовый авангард. -2. Случайно определяется первый игрок. **В первый ход атаковать нельзя.** +2. Случайно определяется первый игрок. **В первый ход атаковать нельзя**; + переход в Battle на первом ходу также недоступен. 3. Оба игрока перемешивают колоду и берут по **5 карт**. 4. **Вторая попытка (mulligan):** игрок может вернуть **любое количество** карт из руки в низ колоды, взять столько же карт сверху, после чего колода diff --git a/server/src/Vanguard.Rules/Engine/GameEngine.cs b/server/src/Vanguard.Rules/Engine/GameEngine.cs index bcfd942..5789ecf 100644 --- a/server/src/Vanguard.Rules/Engine/GameEngine.cs +++ b/server/src/Vanguard.Rules/Engine/GameEngine.cs @@ -1412,9 +1412,11 @@ public sealed class GameEngine break; case Phase.Ride: + var rideGrade = VanguardGrade(player); foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct()) { - if (State.Catalog.CardOf(printingId).Grade == VanguardGrade(player) + 1) + var grade = State.Catalog.CardOf(printingId).Grade; + if (grade == rideGrade || grade == rideGrade + 1) commands.Add(new RideCommand(printingId)); } @@ -1471,7 +1473,8 @@ public sealed class GameEngine foreach (var card in p.Drop) AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true); - commands.Add(new EnterBattleCommand()); + if (!_enforceFirstTurnAttackRule || State.TurnNumber != 1) + commands.Add(new EnterBattleCommand()); commands.Add(new EndTurnCommand()); } diff --git a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs index c1e615c..59a0cf1 100644 --- a/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs +++ b/server/tests/Vanguard.Rules.Tests/GameEngineTests.cs @@ -94,6 +94,43 @@ public class GameEngineTests Assert.False(engine.Execute(new AttackCommand(-1, -1)).Accepted); } + [Fact] + public void First_Turn_Does_Not_Offer_Battle_Phase() + { + var engine = new GameEngine( + _catalog, + 7, + BuildDeck(), + BuildDeck(), + requireLegalDeckTriggers: false); + engine.Start(firstPlayer: 0); + engine.Execute(new FinishMulliganCommand()); + engine.Execute(new FinishMulliganCommand()); + + Assert.DoesNotContain( + engine.LegalCommands(), + command => command is EnterBattleCommand); + + engine.Execute(new SkipRideCommand()); + + Assert.DoesNotContain( + engine.LegalCommands(), + command => command is EnterBattleCommand); + } + + [Fact] + public void Ride_Can_Use_Same_Grade() + { + var engine = StartedEngine(); + Give(engine, 0, Conroe); + + Assert.Contains( + engine.LegalCommands(), + command => command is RideCommand ride && ride.PrintingId == Conroe); + + Assert.True(engine.Execute(new RideCommand(Conroe)).Accepted); + } + [Fact] public void Both_Players_Mulligan_Once_Then_First_Player_Starts() {