Боевая система с типами войск

Модуль Боя (Bogatyri.Battle, без Unity и float):
- сила стороны по типам войск, сила воина — из GameData.Troops (4/5/9/16)
- контры +25%: пехота > кавалерия > лучники > пехота (по составу врага)
- стены +20% защитникам; осадные +50% против стен, −25% в поле
- потери по типам: проигравший — половина, победитель — до половины (чем ровнее бой, тем больше)
- BattleRequest/BattleOutcome расширены (состав, стены, бонусы, сила сторон); бой «числом» считается пехотой
- формула — предложение, числа константами в BattleSystem.cs, ждёт утверждения командой

Мир и Армия:
- состав гарнизона у каждой цели (WorldMapLayout.GarrisonUnits, из id — карта не изменилась)
- поход хранит состав по типам; выжившие возвращаются точно по типам (IArmy.GetMarchUnits/ReturnUnits)
- время походов по GameClock: перемотка в DEV-панели проматывает и походы

Интерфейс карты:
- окно атаки: состав гарнизона и ожидаемый исход (сила сторон, победа/поражение, потери)
- верхняя панель: воины в походах (а не число отрядов)

Тесты: контры, стены, осадные, потери в пределах состава, состав гарнизонов.
Документы Battle, World, Army, Core, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-27 16:34:58 +01:00
co-authored by Claude Opus 5.5
parent f188c3a8eb
commit 460d1db2de
14 changed files with 406 additions and 35 deletions
+4 -1
View File
@@ -13,7 +13,10 @@ public static class GameModules
static void Register()
{
// Боевая система -- чистый C#, сама зарегистрироваться не может
GameServices.Register<IBattleSystem>(new BattleSystem());
// Сила воина каждого типа -- из GameData.Troops, чтобы числа были в одном месте
var unitPower = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
GameServices.Register<IBattleSystem>(new BattleSystem(unitPower));
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService();
@@ -99,6 +99,26 @@ public sealed class ArmyService : IArmy, ISaveParticipant
Changed();
}
public int[] GetMarchUnits(int marchId)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
return entry != null ? (int[])entry.units.Clone() : null;
}
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
public void ReturnUnits(int marchId, int[] survivorUnits)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++)
{
int back = Math.Max(0, survivorUnits[i]);
if (entry != null) back = Math.Min(back, entry.units[i]);
troops[i] += back;
}
if (entry != null) marches.Remove(entry);
Changed();
}
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
+118 -11
View File
@@ -1,26 +1,133 @@
using System;
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
//
// Правила пока самые простые: побеждает тот, у кого больше солдат.
// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся.
// Правила (предложение, утверждает команда -- открытый вопрос этапа 6 плана):
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты (+25%);
// бонус типа против врага взвешивается по составу врага;
// - осадные: против стен +50%, в поле -25%; стены дают защитникам +20%;
// - побеждает тот, у кого сила больше; ничья -- защитнику;
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
public sealed class BattleSystem : IBattleSystem
{
// Сила одного воина каждого типа. Берётся из GameData.Troops (App/GameModules), здесь -- запасные значения.
public static readonly int[] DefaultPower = { 4, 5, 9, 16 };
public const int CounterPercent = 25;
public const int SiegeVsWallsPercent = 50;
public const int SiegeInFieldPercent = -25;
public const int WallsPercent = 20;
public const int LoserLossPercent = 50;
private readonly int[] power;
public BattleSystem() : this(DefaultPower) { }
public BattleSystem(int[] unitPower)
{
power = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
power[i] = unitPower != null && i < unitPower.Length && unitPower[i] > 0 ? unitPower[i] : DefaultPower[i];
}
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
public static int Counter(int attackerType, int defenderType)
{
if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return CounterPercent;
if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return CounterPercent;
if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return CounterPercent;
return 0;
}
public BattleOutcome Resolve(BattleRequest request)
{
int attackers = request.Attackers;
int defenders = request.Defenders;
bool victory = attackers > defenders; // ничья -- в пользу защитника
int[] att = Units(request.AttackerUnits, request.Attackers);
int[] def = Units(request.DefenderUnits, request.Defenders);
// Проигравший теряет половину своего войска, победитель -- столько же
int loserArmy = victory ? defenders : attackers;
int losses = (loserArmy + 1) / 2;
long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent);
long defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
request.DefenderBonusPercent + (request.DefenderFortified ? WallsPercent : 0));
bool victory = attPower > defPower; // ничья -- в пользу защитника
// Потери в процентах: проигравший -- половина; победитель -- половина x (сила проигравшего / сила победителя)
long stronger = Math.Max(attPower, defPower);
long weaker = Math.Min(attPower, defPower);
int[] attLost = Losses(att, weaker, stronger, isLoser: !victory);
int[] defLost = Losses(def, weaker, stronger, isLoser: victory);
var survivors = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) survivors[i] = att[i] - attLost[i];
return new BattleOutcome
{
Victory = victory,
AttackerLosses = losses,
DefenderLosses = losses,
Survivors = attackers - losses
AttackerLosses = Sum(attLost),
DefenderLosses = Sum(defLost),
Survivors = Sum(survivors),
AttackerPower = attPower,
DefenderPower = defPower,
AttackerSurvivorUnits = survivors,
DefenderLossUnits = defLost
};
}
// Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах
long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent)
{
int enemyTotal = Sum(enemy);
long total = 0;
for (int i = 0; i < TroopType.Count; i++)
{
if (units[i] <= 0) continue;
long counterSum = 0;
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0;
int siegePercent = 0;
if (i == TroopType.Siege) siegePercent = attacking && fortified ? SiegeVsWallsPercent : SiegeInFieldPercent;
total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent);
}
return Math.Max(0, total * (100 + bonusPercent) / 100);
}
// Потери каждого типа: проигравший -- LoserLossPercent; победитель -- LoserLossPercent x ratioNum / ratioDen
static int[] Losses(int[] units, long ratioNum, long ratioDen, bool isLoser)
{
var lost = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
if (units[i] <= 0) continue;
long l = isLoser
? (long)units[i] * LoserLossPercent / 100
: ratioDen > 0 ? (long)units[i] * LoserLossPercent * ratioNum / (100 * ratioDen) : 0;
lost[i] = (int)Math.Min(units[i], l);
}
return lost;
}
static int[] Units(int[] units, int count)
{
var result = new int[TroopType.Count];
if (units != null)
{
for (int i = 0; i < TroopType.Count && i < units.Length; i++) result[i] = Math.Max(0, units[i]);
}
else
{
result[TroopType.Infantry] = Math.Max(0, count); // состав неизвестен -- считаем пехотой
}
return result;
}
static int Sum(int[] values)
{
int n = 0;
foreach (var v in values) n += v;
return n;
}
}
@@ -65,6 +65,11 @@ public interface IArmy
bool TakeForMarch(int marchId, int count);
// Вернуть выживших из похода по тем же типам войск
void ReturnFromMarch(int marchId, int survivors);
// Состав похода по типам (копия); null -- такого похода нет
int[] GetMarchUnits(int marchId);
// Вернуть выживших точно по типам (бой посчитал потери каждого типа)
void ReturnUnits(int marchId, int[] survivorUnits);
}
// ---------------- Глобальный мир: карта и перемещения ----------------
@@ -75,10 +80,28 @@ public interface IWorld
}
// ---------------- Боевая система: урон, потери, исход ----------------
// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии
public static class TroopType
{
public const int Infantry = 0; // пехота
public const int Archers = 1; // лучники
public const int Cavalry = 2; // кавалерия
public const int Siege = 3; // осадные
public const int Count = 4;
}
public struct BattleRequest
{
// Состав сторон по типам войск (TroopType). Если состав не задан (null) --
// берётся число Attackers / Defenders, и все считаются пехотой.
public int[] AttackerUnits;
public int[] DefenderUnits;
public int Attackers;
public int Defenders;
public bool DefenderFortified; // защитники за стенами (город, замок)
public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее
public int DefenderBonusPercent;
}
public struct BattleOutcome
@@ -87,6 +110,11 @@ public struct BattleOutcome
public int AttackerLosses;
public int DefenderLosses;
public int Survivors; // сколько атакующих осталось
public long AttackerPower; // сила сторон с учётом контр, стен и бонусов
public long DefenderPower;
public int[] AttackerSurvivorUnits; // выжившие атакующие по типам
public int[] DefenderLossUnits; // потери защитников по типам
}
public interface IBattleSystem
@@ -128,6 +128,71 @@ public class ModuleTests
Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors);
}
[Test]
public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry()
{
var battle = new BattleSystem();
// Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124
var archersVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } });
Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту");
var cavalryVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } });
Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию");
}
[Test]
public void Battle_WallsHelpDefenders_SiegeBreaksWalls()
{
var battle = new BattleSystem();
var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
Assert.IsTrue(field.Victory);
Assert.IsFalse(walls.Victory, "те же войска за стенами устояли");
var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле");
}
[Test]
public void Battle_LossesPerType_NeverExceedUnits_AndSumUp()
{
var battle = new BattleSystem();
var rnd = new Random(7);
for (int run = 0; run < 500; run++)
{
var att = new int[4]; var def = new int[4];
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); }
var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 });
int survivors = 0, defLost = 0;
for (int i = 0; i < 4; i++)
{
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]));
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]));
survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.Survivors, survivors);
Assert.AreEqual(o.DefenderLosses, defLost);
Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors);
}
}
[Test]
public void World_GarrisonComposition_SumsToGarrison_AndIsStable()
{
foreach (var node in WorldMapLayout.CreateDefault())
{
int[] a = WorldMapLayout.GarrisonUnits(node);
int[] b = WorldMapLayout.GarrisonUnits(node);
int sum = 0;
for (int i = 0; i < 4; i++) { Assert.GreaterOrEqual(a[i], 0); Assert.AreEqual(a[i], b[i]); sum += a[i]; }
Assert.AreEqual(node.garrison, sum, node.displayName);
}
}
// ---------------- Глобальный мир: поход до конца, в том числе офлайн ----------------
[Test]
+10 -4
View File
@@ -27,8 +27,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
// Время мира. В мультиплеере -- время сервера, а не телефона
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000;
private readonly List<WorldEvent> events = new List<WorldEvent>();
private WorldSimulation world;
@@ -108,8 +109,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
if (march == null) return false;
// Ушедшие воины недоступны дома, пока не вернутся
// Ушедшие воины недоступны дома, пока не вернутся. Состав похода -- для боя с типами войск
army.TakeForMarch(march.id, soldiers);
march.units = army.GetMarchUnits(march.id);
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
@@ -137,7 +139,11 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
// Солдаты и добыча зачисляются, только когда войско дошло до дома
void HandleMarchReturned(MarchData march)
{
GameServices.Get<IArmy>().ReturnFromMarch(march.id, march.survivors);
IArmy army = GameServices.Get<IArmy>();
if (march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count)
army.ReturnUnits(march.id, march.survivorUnits); // бой посчитал потери по типам
else
army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции
IEconomy economy = GameServices.Get<IEconomy>();
foreach (var reward in march.loot)
@@ -157,6 +157,46 @@ namespace Bogatyri.World
return false;
}
// Состав гарнизона по типам войск (TroopType). Вычисляется из id цели, а не из генератора карты:
// так карта (координаты, гарнизоны) осталась прежней, а состав всегда одинаков у клиента и сервера.
public static int[] GarrisonUnits(MapNode node)
{
var units = new int[TroopType.Count];
if (node == null || node.garrison <= 0) return units;
int[] percent;
if (node.type == MapNodeType.BossCastle)
{
percent = new[] { 40, 30, 20, 10 }; // Змей Горыныч держит всех понемногу
}
else
{
uint h = StableHash(node.id);
int infantry = 30 + (int)(h % 31); // 30..60%
int archers = 10 + (int)(h / 31 % 26); // 10..35%
int cavalry = 5 + (int)(h / 806 % 21); // 5..25%
int siege = Math.Max(0, 100 - infantry - archers - cavalry);
percent = new[] { infantry, archers, cavalry, siege };
}
int given = 0;
for (int i = 0; i < TroopType.Count; i++)
{
units[i] = node.garrison * percent[i] / 100;
given += units[i];
}
units[TroopType.Infantry] += node.garrison - given; // остаток округления -- пехоте
return units;
}
// FNV-1a: одинаков на любой платформе (string.GetHashCode в .NET меняется между запусками)
static uint StableHash(string s)
{
uint hash = 2166136261;
foreach (char c in s ?? "") hash = (hash ^ c) * 16777619;
return hash;
}
public static MapNode Find(List<MapNode> nodes, string id)
{
foreach (var n in nodes)
@@ -25,6 +25,8 @@ namespace Bogatyri.World
public bool victory;
public int defenders; // гарнизон цели в момент боя
public int defenderLosses; // сколько потерял защитник
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
public int[] survivorUnits; // выжившие по типам (известно после боя)
public List<RewardEntry> loot = new List<RewardEntry>();
public long departMs;
@@ -151,9 +153,17 @@ namespace Bogatyri.World
MapNode from = GetNode(m.fromNodeId);
MapNode target = GetNode(m.targetNodeId);
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { Attackers = m.soldiers, Defenders = target.garrison });
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest
{
AttackerUnits = HasUnits(m) ? m.units : null,
Attackers = m.soldiers,
DefenderUnits = WorldMapLayout.GarrisonUnits(target),
Defenders = target.garrison,
DefenderFortified = target.type != MapNodeType.PlayerCity // замок и города -- за стенами
});
m.victory = outcome.Victory;
m.survivors = outcome.Survivors;
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
m.defenders = target.garrison;
m.defenderLosses = outcome.DefenderLosses;
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
@@ -170,6 +180,15 @@ namespace Bogatyri.World
m.state = MarchState.Returning;
}
// Состав похода известен и сходится с числом воинов (у старых походов его нет)
static bool HasUnits(MarchData m)
{
if (m.units == null || m.units.Length != TroopType.Count) return false;
int n = 0;
foreach (var u in m.units) n += u;
return n == m.soldiers && n > 0;
}
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
{
+42 -3
View File
@@ -108,7 +108,9 @@ public class WorldMapController : MonoBehaviour
HandleMapTap();
SyncMarchViews();
soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}";
// Воины, а не отряды: "В походе: 1" читалось как один воин
int inMarches = GameServices.TryGet(out IArmy army) ? army.TroopsInMarches : 0;
soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {inMarches} (отрядов: {World.Marches.Count})";
}
void HookUpComingSoon(Button button, string sectionName)
@@ -244,7 +246,10 @@ public class WorldMapController : MonoBehaviour
attackTitleText.text = target.type == MapNodeType.NeighborCity
? $"Город игрока {target.displayName}"
: target.displayName;
attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" +
attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает
attackInfoText.fontSizeMin = 18;
attackInfoText.fontSizeMax = 32;
attackInfoText.text = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" +
$"Путь в одну сторону: {travelSeconds} сек\n" +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
@@ -262,11 +267,45 @@ public class WorldMapController : MonoBehaviour
void UpdateSoldiersToSendText()
{
int available = GetSoldiersAtHome();
// Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась
soldiersToSendText.enableAutoSizing = true;
soldiersToSendText.fontSizeMin = 18;
soldiersToSendText.fontSizeMax = 32;
soldiersToSendText.overflowMode = TMPro.TextOverflowModes.Truncate;
var size = soldiersToSendText.rectTransform.sizeDelta;
if (size.y < 76) soldiersToSendText.rectTransform.sizeDelta = new Vector2(size.x, 76);
soldiersToSendText.text = available > 0
? $"Отправить: {(int)soldiersSlider.value} из {available}"
? $"Отправить: {(int)soldiersSlider.value} из {available}\n{PreviewText((int)soldiersSlider.value)}"
: "Дома нет солдат";
}
// Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия
string PreviewText(int soldiers)
{
if (selectedTarget == null || soldiers <= 0) return "";
IArmy army = GameServices.Get<IArmy>();
var troops = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i);
int[] units = ArmyMath.TakeProportional(troops, soldiers);
if (units == null) return "";
BattleOutcome o = GameServices.Get<IBattleSystem>().Resolve(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
DefenderFortified = true
});
string result = o.Victory ? "<color=#7CD67C>скорее победа</color>" : "<color=#E06A5A>скорее поражение</color>";
return $"сила {o.AttackerPower / 100} против {o.DefenderPower / 100} -- {result}, потери ~{o.AttackerLosses}";
}
static string UnitsText(int[] units)
{
return $"пех. {units[TroopType.Infantry]}, луч. {units[TroopType.Archers]}, " +
$"кав. {units[TroopType.Cavalry]}, осад. {units[TroopType.Siege]}";
}
void CloseAttackPanel()
{
attackPanel.SetActive(false);