Fix first-turn battle, same-grade ride, ability and guard choices, center camera
CI / test (push) Successful in 1m53s
Details
CI / test (push) Successful in 1m53s
Details
This commit is contained in:
parent
dfd766eaad
commit
30aed3b25d
|
|
@ -54,12 +54,12 @@ namespace Vanguard.App
|
||||||
camera.fieldOfView = theme == SceneTheme.Battle ? 46f : 52f;
|
camera.fieldOfView = theme == SceneTheme.Battle ? 46f : 52f;
|
||||||
camera.transform.position = theme switch
|
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),
|
SceneTheme.Login => new Vector3(0f, 2.4f, -9.5f),
|
||||||
_ => new Vector3(0f, 3.2f, -10f),
|
_ => new Vector3(0f, 3.2f, -10f),
|
||||||
};
|
};
|
||||||
camera.transform.rotation = theme == SceneTheme.Battle
|
camera.transform.rotation = theme == SceneTheme.Battle
|
||||||
? Quaternion.Euler(35.9f, 0f, 0f)
|
? Quaternion.Euler(27.7f, 0f, 0f)
|
||||||
: Quaternion.Euler(8f, 0f, 0f);
|
: Quaternion.Euler(8f, 0f, 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ MonoBehaviour:
|
||||||
damageX: 3
|
damageX: 3
|
||||||
pileX: 2.95
|
pileX: 2.95
|
||||||
showZoneLabels: 0
|
showZoneLabels: 0
|
||||||
cameraPosition: {x: 1.4, y: 5.8, z: -8}
|
cameraPosition: {x: 0, y: 4.2, z: -8}
|
||||||
cameraTarget: {x: 1.4, y: 0, z: 0}
|
cameraTarget: {x: 0, y: 0, z: 0}
|
||||||
cameraFov: 46
|
cameraFov: 46
|
||||||
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
|
tableColor: {r: 0.045, g: 0.04, b: 0.055, a: 1}
|
||||||
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}
|
matSelfColor: {r: 0.055, g: 0.095, b: 0.16, a: 1}
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ namespace Vanguard.Board3D
|
||||||
|
|
||||||
[Header("Камера")]
|
[Header("Камера")]
|
||||||
[Tooltip("Фиксированная камера над полем. Не двигается игроком.")]
|
[Tooltip("Фиксированная камера над полем. Не двигается игроком.")]
|
||||||
public Vector3 cameraPosition = new Vector3(1.4f, 5.8f, -8.0f);
|
public Vector3 cameraPosition = new Vector3(0f, 4.2f, -8f);
|
||||||
public Vector3 cameraTarget = new Vector3(1.4f, 0f, 0f);
|
public Vector3 cameraTarget = new Vector3(0f, 0f, 0f);
|
||||||
public float cameraFov = 46f;
|
public float cameraFov = 46f;
|
||||||
|
|
||||||
[Header("Палитра")]
|
[Header("Палитра")]
|
||||||
|
|
|
||||||
|
|
@ -1086,8 +1086,7 @@ namespace Vanguard.UI
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenCardMenu(BoardCardRef card)
|
private void OpenCardMenu(BoardCardRef card)
|
||||||
{
|
{ if (_view == null || _board3D == null)
|
||||||
if (_view == null || _board3D == null)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var target = new BoardTarget(
|
var target = new BoardTarget(
|
||||||
|
|
@ -1127,9 +1126,17 @@ namespace Vanguard.UI
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var abilities = sources.OfType<ActivateAbilityIntent>().ToList();
|
||||||
|
if (abilities.Count > 0)
|
||||||
|
{
|
||||||
|
actions.Add(new CardActionMenuItem(
|
||||||
|
"Активировать способность",
|
||||||
|
() => OpenAbilitySelector(abilities)));
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var intent in sources)
|
foreach (var intent in sources)
|
||||||
{
|
{
|
||||||
if (intent is AttackIntent or MoveIntent)
|
if (intent is AttackIntent or MoveIntent or ActivateAbilityIntent)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var captured = intent;
|
var captured = intent;
|
||||||
|
|
@ -1225,6 +1232,159 @@ namespace Vanguard.UI
|
||||||
OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false));
|
OnBoardTargetClicked(new BoardTarget(slot.Player, slot.Slot, null, 0, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddAbilityButton(IReadOnlyList<ActivateAbilityIntent> 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<ActivateAbilityIntent> 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<ActivateAbilityIntent> 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<GuardIntent> 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<CardActionMenuItem> 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<CardView>();
|
||||||
|
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)
|
private void OnBoard3DCardDropped(BoardCardRef source, BoardCardRef target)
|
||||||
{
|
{
|
||||||
if (_view == null || _sending || _inputLock.IsLocked)
|
if (_view == null || _sending || _inputLock.IsLocked)
|
||||||
|
|
@ -1237,8 +1397,16 @@ namespace Vanguard.UI
|
||||||
{
|
{
|
||||||
if (target.Zone == BoardZone.Guardian)
|
if (target.Zone == BoardZone.Guardian)
|
||||||
{
|
{
|
||||||
intent = legal.OfType<GuardIntent>()
|
var guards = legal.OfType<GuardIntent>()
|
||||||
.FirstOrDefault(item => item.PrintingId == source.PrintingId);
|
.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)
|
else if (target.Player == _view.You && target.Zone == BoardZone.Vanguard)
|
||||||
{
|
{
|
||||||
|
|
@ -1354,6 +1522,7 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
var intents = (view.LegalIntents ?? new List<IntentPayload>())
|
var intents = (view.LegalIntents ?? new List<IntentPayload>())
|
||||||
.Where(IsPhaseIntent)
|
.Where(IsPhaseIntent)
|
||||||
|
.Where(intent => !(intent is EnterBattleIntent && view.TurnNumber == 1))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (intents.Count == 0)
|
if (intents.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -1696,6 +1865,13 @@ namespace Vanguard.UI
|
||||||
.Where(intent => TryGetSource(intent, out var source) && source.Matches(target))
|
.Where(intent => TryGetSource(intent, out var source) && source.Matches(target))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
var guards = sources.OfType<GuardIntent>().ToList();
|
||||||
|
if (guards.Count > 0 && guards.Count == sources.Count)
|
||||||
|
{
|
||||||
|
ShowGuardDiscardChoice(guards);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (sources.Count == 0)
|
if (sources.Count == 0)
|
||||||
{
|
{
|
||||||
SetStatus("Для этой карты сейчас нет доступного действия.", Muted);
|
SetStatus("Для этой карты сейчас нет доступного действия.", Muted);
|
||||||
|
|
@ -1713,6 +1889,28 @@ namespace Vanguard.UI
|
||||||
.Where(intent => TryGetTarget(intent, out _))
|
.Where(intent => TryGetTarget(intent, out _))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
if (withTargets.Count == 0 && sources.Count > 1)
|
||||||
|
{
|
||||||
|
var abilities = sources.OfType<ActivateAbilityIntent>().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)
|
if (withTargets.Count == 0)
|
||||||
{
|
{
|
||||||
ClearSelection(rebuild: false);
|
ClearSelection(rebuild: false);
|
||||||
|
|
@ -1956,9 +2154,14 @@ namespace Vanguard.UI
|
||||||
|
|
||||||
var legal = view.LegalIntents ?? new List<IntentPayload>();
|
var legal = view.LegalIntents ?? new List<IntentPayload>();
|
||||||
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
|
var playable = legal.Where(intent => intent is not ConcedeIntent).ToList();
|
||||||
var board = playable.Where(IsBoardIntent).ToList();
|
var abilities = playable.OfType<ActivateAbilityIntent>().ToList();
|
||||||
|
var board = playable
|
||||||
|
.Where(intent => IsBoardIntent(intent) && intent is not ActivateAbilityIntent)
|
||||||
|
.ToList();
|
||||||
var global = playable
|
var global = playable
|
||||||
.Where(intent => !IsBoardIntent(intent) && !IsPhaseIntent(intent))
|
.Where(intent => !IsBoardIntent(intent)
|
||||||
|
&& !IsPhaseIntent(intent)
|
||||||
|
&& intent is not ActivateAbilityIntent)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (_pendingIntents.Count > 0)
|
if (_pendingIntents.Count > 0)
|
||||||
|
|
@ -2065,6 +2268,9 @@ namespace Vanguard.UI
|
||||||
UiKit.SetPreferredHeight(toggle, 38f);
|
UiKit.SetPreferredHeight(toggle, 38f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (abilities.Count > 0)
|
||||||
|
AddAbilityButton(abilities);
|
||||||
|
|
||||||
foreach (var intent in global)
|
foreach (var intent in global)
|
||||||
AddIntentButton(intent);
|
AddIntentButton(intent);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,14 @@ namespace Vanguard.UI
|
||||||
["Атака "] = "Attack ",
|
["Атака "] = "Attack ",
|
||||||
["Атака"] = "Attack",
|
["Атака"] = "Attack",
|
||||||
["Атака с поддержкой: "] = "Boosted attack: ",
|
["Атака с поддержкой: "] = "Boosted attack: ",
|
||||||
|
["Активировать способность"] = "Activate ability",
|
||||||
|
["Активировать: "] = "Activate: ",
|
||||||
|
["Сброс для способности"] = "Discard for ability",
|
||||||
|
["Сбросить: "] = "Discard: ",
|
||||||
|
["Без сброса"] = "No discard",
|
||||||
|
["Guard без сброса"] = "Guard without discard",
|
||||||
|
["Perfect Guard: сбросить "] = "Perfect Guard: discard ",
|
||||||
|
["Действия"] = "Actions",
|
||||||
["Переместить"] = "Move",
|
["Переместить"] = "Move",
|
||||||
["Подробнее"] = "Details",
|
["Подробнее"] = "Details",
|
||||||
["Нет доступных действий"] = "No available actions",
|
["Нет доступных действий"] = "No available actions",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@ namespace Vanguard.Tests
|
||||||
{
|
{
|
||||||
public sealed class PlaymatLayoutTests
|
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]
|
[Test]
|
||||||
public void Default_Playmat_Has_Six_Circles_Per_Side()
|
public void Default_Playmat_Has_Six_Circles_Per_Side()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
|
||||||
|
|
||||||
| Проверка | Результат |
|
| Проверка | Результат |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Тесты сервера | **184/184** (139 правил/контрактов + 45 сервер) |
|
| Тесты сервера | **186/186** (141 правил/контрактов + 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 ошибок |
|
||||||
|
|
@ -858,8 +858,9 @@ Battle; Boot и общие сервисы остаются персистент
|
||||||
- `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики,
|
- `GameBoard3D` строит стол, круги, зоны, стопки и веер руки; raycast-клики,
|
||||||
drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и
|
drag&drop на плоскость стола с подъёмом, подсветкой допустимых кругов и
|
||||||
индикатором точки броска. Ошибка интента возвращает карту на место.
|
индикатором точки броска. Ошибка интента возвращает карту на место.
|
||||||
- Камера фиксирована над полем: позиция `(1.4, 5.8, -8)`, цель
|
- Камера фиксирована по центру поля: позиция `(0, 4.2, -8)`, цель
|
||||||
`(1.4, 0, 0)`, FOV 46; игрок её не двигает. Рука не лежит на столе,
|
`(0, 0, 0)`, FOV 46; наклон только по X, без бокового смещения, игрок её
|
||||||
|
не двигает. Рука не лежит на столе,
|
||||||
а закреплена в экране снизу по центру (`handViewportX/Y`,
|
а закреплена в экране снизу по центру (`handViewportX/Y`,
|
||||||
`handViewDistance`) и всегда развёрнута лицом к игроку; уменьшена, чтобы
|
`handViewDistance`) и всегда развёрнута лицом к игроку; уменьшена, чтобы
|
||||||
не перекрывать задний ряд RC. Драг из руки уходит на стол.
|
не перекрывать задний ряд RC. Драг из руки уходит на стол.
|
||||||
|
|
@ -958,7 +959,7 @@ Battle; Boot и общие сервисы остаются персистент
|
||||||
- Добавлен `Battle_Layout_Fits_Reference_Resolution`: проверяет блоки зон,
|
- Добавлен `Battle_Layout_Fits_Reference_Resolution`: проверяет блоки зон,
|
||||||
панель фаз и боевую плашку на 1920×1080 и отсутствие пересечений; при
|
панель фаз и боевую плашку на 1920×1080 и отсутствие пересечений; при
|
||||||
другом разрешении CanvasScaler масштабирует reference-раскладку.
|
другом разрешении 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`.
|
`PLAYMAT_SMOKE success=True cards=25 hand=5/5 fieldVisible=True`.
|
||||||
- Дальше: только 3D-модели/наполнение сцен, анимации retire/ghost и
|
- Дальше: только 3D-модели/наполнение сцен, анимации retire/ghost и
|
||||||
декбилдер; prefab-оверлеи и раскладка закрыты.
|
декбилдер; prefab-оверлеи и раскладка закрыты.
|
||||||
|
|
@ -978,3 +979,21 @@ Battle; Boot и общие сервисы остаются персистент
|
||||||
не показателен: runtime-материалы требуют графического устройства.
|
не показателен: runtime-материалы требуют графического устройства.
|
||||||
- Сервер для standalone по умолчанию — `http://192.168.31.158:18080`;
|
- Сервер для 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**.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
матчмейкинг, WebSocket-матч, история, реплеи.
|
матчмейкинг, WebSocket-матч, история, реплеи.
|
||||||
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
|
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
|
||||||
in-process реестр соединений.
|
in-process реестр соединений.
|
||||||
- Тесты: **139** правил + **45** серверных, CI зелёный.
|
- Тесты: **141** правил + **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/game-rules.md`, `docs/HANDOFF.md`.
|
`docs/game-rules.md`, `docs/HANDOFF.md`.
|
||||||
|
|
@ -68,10 +68,10 @@
|
||||||
Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`),
|
Рубашка — стандартная VF Vanguard (`Resources/CardArt/vanguard_back.png`),
|
||||||
общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру
|
общая для 2D и 3D. Рука закреплена в поле зрения камеры снизу по центру
|
||||||
и всегда смотрит на игрока, а не лежит на столе; уменьшена, чтобы не
|
и всегда смотрит на игрока, а не лежит на столе; уменьшена, чтобы не
|
||||||
закрывать задний ряд RC. Камера фиксирована над полем под небольшим
|
закрывать задний ряд RC. Камера фиксирована по центру поля под небольшим
|
||||||
углом и не двигается игроком: `(1.4, 5.8, -8)`, цель `(1.4, 0, 0)`,
|
углом к горизонту и не двигается игроком: `(0, 4.2, -8)`, цель
|
||||||
FOV 46. Драг-н-дроп на плоскость стола с подсветкой целей и плавным
|
`(0, 0, 0)`, FOV 46; наклон только по X, без бокового смещения. Драг-н-дроп
|
||||||
снапом.
|
на плоскость стола с подсветкой целей и плавным снапом.
|
||||||
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
|
- Поле: у каждого игрока два ряда `R V R` / `R R R` (V + 5 RC), у
|
||||||
противника зеркально; между полями общий круг защиты; колода, дроп,
|
противника зеркально; между полями общий круг защиты; колода, дроп,
|
||||||
триггер-зона и урон — 3D-зоны. Локальный игрок всегда снизу, даже если
|
триггер-зона и урон — 3D-зоны. Локальный игрок всегда снизу, даже если
|
||||||
|
|
@ -79,6 +79,12 @@
|
||||||
лицом вверх; урон — один вертикальный ряд, карта входит открытой.
|
лицом вверх; урон — один вертикальный ряд, карта входит открытой.
|
||||||
Над колодой — зона проверки триггера. Под картами V/R — динамические
|
Над колодой — зона проверки триггера. Под картами V/R — динамические
|
||||||
`P/C`.
|
`P/C`.
|
||||||
|
- Смена: в фазе Ride доступны карты того же ранга и на 1 выше. На первом
|
||||||
|
ходу переход в Battle не предлагается; атака по-прежнему запрещена.
|
||||||
|
- Способности: кнопка «Активировать способность» одна на карту; при
|
||||||
|
нескольких вариантах открывается overlay выбора, затем — выбор карты
|
||||||
|
для сброса. Perfect Guard также спрашивает карту для сброса отдельным
|
||||||
|
overlay, а не выбирает случайную.
|
||||||
- Статистика карт — отдельное UI-наложение: своя панель снизу слева,
|
- Статистика карт — отдельное UI-наложение: своя панель снизу слева,
|
||||||
противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер`
|
противника — сверху справа. Блоки `Рука/Колода/Дроп/Душа/Урон/Защита/Триггер`
|
||||||
кликабельны и открывают общий горизонтальный `CardListView`; из списка
|
кликабельны и открывают общий горизонтальный `CardListView`; из списка
|
||||||
|
|
@ -175,7 +181,7 @@ Standalone Windows:
|
||||||
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
-executeMethod Vanguard.Editor.SceneSmoke.RunPlaymat
|
||||||
```
|
```
|
||||||
|
|
||||||
Клиентские проверки: **28/28** EditMode (сборка всех экранов, импорт арта,
|
Клиентские проверки: **29/29** EditMode (сборка всех экранов, импорт арта,
|
||||||
перевод статических/динамических строк, live-переключение языка, рендер
|
перевод статических/динамических строк, live-переключение языка, рендер
|
||||||
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
|
3D-доски с уроном и отдыхающей картой, геометрия поля, правила
|
||||||
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
|
перемещения, рука в поле зрения камеры, обмен колоды/дропа, триггер-зона,
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,8 @@
|
||||||
|
|
||||||
1. Каждый игрок выбирает карту 0 ранга и кладёт её **закрытой** в круг
|
1. Каждый игрок выбирает карту 0 ранга и кладёт её **закрытой** в круг
|
||||||
авангарда — это стартовый авангард.
|
авангарда — это стартовый авангард.
|
||||||
2. Случайно определяется первый игрок. **В первый ход атаковать нельзя.**
|
2. Случайно определяется первый игрок. **В первый ход атаковать нельзя**;
|
||||||
|
переход в Battle на первом ходу также недоступен.
|
||||||
3. Оба игрока перемешивают колоду и берут по **5 карт**.
|
3. Оба игрока перемешивают колоду и берут по **5 карт**.
|
||||||
4. **Вторая попытка (mulligan):** игрок может вернуть **любое количество**
|
4. **Вторая попытка (mulligan):** игрок может вернуть **любое количество**
|
||||||
карт из руки в низ колоды, взять столько же карт сверху, после чего колода
|
карт из руки в низ колоды, взять столько же карт сверху, после чего колода
|
||||||
|
|
|
||||||
|
|
@ -1412,9 +1412,11 @@ public sealed class GameEngine
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Phase.Ride:
|
case Phase.Ride:
|
||||||
|
var rideGrade = VanguardGrade(player);
|
||||||
foreach (var printingId in p.Hand.Select(c => c.PrintingId).Distinct())
|
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));
|
commands.Add(new RideCommand(printingId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1471,6 +1473,7 @@ public sealed class GameEngine
|
||||||
foreach (var card in p.Drop)
|
foreach (var card in p.Drop)
|
||||||
AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true);
|
AddActivatedCommands(commands, card, player, fromHand: false, fromDrop: true);
|
||||||
|
|
||||||
|
if (!_enforceFirstTurnAttackRule || State.TurnNumber != 1)
|
||||||
commands.Add(new EnterBattleCommand());
|
commands.Add(new EnterBattleCommand());
|
||||||
commands.Add(new EndTurnCommand());
|
commands.Add(new EndTurnCommand());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,43 @@ public class GameEngineTests
|
||||||
Assert.False(engine.Execute(new AttackCommand(-1, -1)).Accepted);
|
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]
|
[Fact]
|
||||||
public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
|
public void Both_Players_Mulligan_Once_Then_First_Player_Starts()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue