Feature/battle troop types #16
@@ -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 });
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). |
|
||||
| `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. |
|
||||
| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. |
|
||||
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. |
|
||||
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). |
|
||||
| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. |
|
||||
| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. |
|
||||
|
||||
Получить: `GameServices.Get<IArmy>()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт.
|
||||
|
||||
@@ -69,6 +71,7 @@ march=<id похода>:<пехота>,<лучники>,<кавалерия>,<о
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | `GetMarchUnits` и `ReturnUnits`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. |
|
||||
| 23.10.2026 | Claude | Войска переехали в модуль: `ArmyService` — единственное хранилище войск (секция `"army"`), `IArmy` расширен (типы, обучение, потери, войска в походах), событие `TroopsChangedEvent`. Перенос из `progress.json` с удалением старых данных только после записи `save.json`. `ArmyBridge` удалён — контракты и перенос сохранений проверены тестами. |
|
||||
| 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). |
|
||||
| 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. |
|
||||
|
||||
+41
-12
@@ -1,8 +1,8 @@
|
||||
# Боевая система
|
||||
|
||||
Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули.
|
||||
Урон, потери, исход боя с учётом типов войск. Модуль только считает: кто победил и сколько погибло каждого типа. Кто идёт в бой и что делать с итогом, решают другие модули.
|
||||
|
||||
- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере).
|
||||
- **Сборка:** `Bogatyri.Battle` (без Unity и без float — тот же код будет считать бои на сервере).
|
||||
- **Папка:** `Assets/Scripts/Modules/Battle`.
|
||||
- **Зависит от:** ядра.
|
||||
|
||||
@@ -10,19 +10,45 @@
|
||||
|
||||
`BattleOutcome Resolve(BattleRequest request)`
|
||||
|
||||
| `BattleRequest` | `BattleOutcome` |
|
||||
| `BattleRequest` | Что это |
|
||||
| --- | --- |
|
||||
| `Attackers` — атакующих | `Victory` — победил атакующий |
|
||||
| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` |
|
||||
| `AttackerUnits`, `DefenderUnits` | Состав сторон по типам (`TroopType`: пехота, лучники, кавалерия, осадные). |
|
||||
| `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. |
|
||||
| `DefenderFortified` | Защитники за стенами (город, замок). |
|
||||
| `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). |
|
||||
|
||||
Регистрирует `App/GameModules`. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
|
||||
| `BattleOutcome` | Что это |
|
||||
| --- | --- |
|
||||
| `Victory` | Победил атакующий. |
|
||||
| `AttackerPower`, `DefenderPower` | Сила сторон ×100 (целые числа): с контрами, стенами и бонусами. |
|
||||
| `AttackerLosses`, `DefenderLosses`, `Survivors` | Потери сторон и выжившие атакующие — всего. |
|
||||
| `AttackerSurvivorUnits`, `DefenderLossUnits` | То же по типам войск. |
|
||||
|
||||
## Текущие правила
|
||||
Регистрирует `App/GameModules`: силу воина каждого типа передаёт из `GameData.Troops` (4 / 5 / 9 / 16), чтобы числа были в одном месте. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
|
||||
|
||||
- Побеждает тот, у кого больше солдат; ничья — победа защитника.
|
||||
- Проигравший теряет половину своего войска, победитель — столько же.
|
||||
## Правила
|
||||
|
||||
Пример: 30 против 20 — победа, погибает 10 атакующих и 10 защитников, домой идут 20.
|
||||
Это **предложение**. Формула боя — открытый вопрос этапа 6 плана, команда должна её утвердить. Все числа — константы в начале `BattleSystem.cs`.
|
||||
|
||||
| Правило | Значение |
|
||||
| --- | --- |
|
||||
| Сила стороны | Сумма по типам: воины × сила типа × (100% + контры + осадный бонус) × (100% + бонусы стороны). |
|
||||
| Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. |
|
||||
| Осадные | Против стен +50%, в поле −25%. |
|
||||
| Стены | Защитники за стенами сильнее на 20%. |
|
||||
| Исход | Побеждает тот, у кого сила больше; ничья — защитнику. |
|
||||
| Потери проигравшего | Половина каждого типа. |
|
||||
| Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. |
|
||||
|
||||
Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами):
|
||||
|
||||
| Атакующие | Сила | Исход | Вернутся |
|
||||
| --- | --- | --- | --- |
|
||||
| 30 пехоты | 126 против 155 | поражение | 15 |
|
||||
| 40 пехоты | 168 против 155 | победа | 22 |
|
||||
| 30 смешанных (12 / 8 / 6 / 4) | 248 против 154 | победа | — |
|
||||
|
||||
Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся.
|
||||
|
||||
## События
|
||||
|
||||
@@ -34,11 +60,14 @@
|
||||
|
||||
## Решения и план
|
||||
|
||||
- **Этап 6:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики.
|
||||
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`.
|
||||
- **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`.
|
||||
- **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода.
|
||||
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. Для этого есть `AttackerBonusPercent` / `DefenderBonusPercent` (минус), проценты — из `EconomyConfig`. Голод уже считает модуль Населения (`IsStarving`).
|
||||
- **Гарнизоны соседей** пока не уменьшаются после боя: соседи — заглушки до сервера.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Бой с типами войск: сила по типам из `GameData.Troops`, контры (пехота > кавалерия > лучники > пехота, +25%), стены +20%, осадные +50% против стен и −25% в поле, потери по типам. `BattleRequest` и `BattleOutcome` расширены, старый бой «числом» считается пехотой. Тесты: контры, стены, осадные, потери в пределах состава. |
|
||||
| 19.10.2026 | Claude | Модуль выделен: `BattleResolver` из `WorldSimulation.cs` стал `BattleSystem` с контрактом `IBattleSystem`; `BattleOutcome` перенесён в ядро. Правила не изменились. |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. |
|
||||
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
|
||||
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
|
||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. |
|
||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. |
|
||||
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
|
||||
| `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. |
|
||||
| `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). |
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | `TroopType` (типы войск), `BattleRequest` / `BattleOutcome` с составом по типам, силой сторон и бонусами; `IArmy.GetMarchUnits`, `IArmy.ReturnUnits`. |
|
||||
| 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. |
|
||||
| 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. |
|
||||
| 19.10.2026 | Claude | Контракт `ICircle` и типы `CircleInfo`, `CircleMember`, `CircleRole`; события `CircleJoinedEvent`, `CircleLeftEvent`; `IChat.CanWrite`. |
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**.
|
||||
|
||||
Уже есть: Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
|
||||
Уже есть: бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
|
||||
|
||||
Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026):
|
||||
|
||||
@@ -61,6 +61,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Боевая система с типами войск (контры, стены, осадные, потери по типам), состав гарнизонов на карте, ожидаемый исход в окне атаки. Обновлены Battle, World, Army, Core. |
|
||||
| 26.10.2026 | Claude | Модуль Населения ([Population.md](Population.md)), таблица баланса `EconomyConfig` (`Assets/Resources/EconomyConfig.asset`), часы игры `GameClock`, отладочная панель «DEV». Обновлены Core, Economy, Save. |
|
||||
| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`), обновлён World. |
|
||||
| 23.10.2026 | Claude | Войска переехали в модуль Армии (`ArmyService`, секция `army`), `ArmyBridge` удалён. Обновлены Army, Core, Save. |
|
||||
|
||||
@@ -50,6 +50,15 @@
|
||||
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей из seed. |
|
||||
| `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. |
|
||||
|
||||
**Бой с типами войск:**
|
||||
- у похода есть состав по типам (`MarchData.units`, берётся у Армии при выступлении) и выжившие по типам (`survivorUnits`);
|
||||
- у цели — состав гарнизона `WorldMapLayout.GarrisonUnits(node)`. Он вычисляется из id цели, а не из генератора карты, поэтому карта осталась прежней. У Змея Горыныча 40% пехоты, 30% лучников, 20% кавалерии, 10% осадных; у соседей пропорции свои у каждого;
|
||||
- замок и города сражаются за стенами;
|
||||
- выжившие возвращаются в Армию точно по типам (`IArmy.ReturnUnits`). Старые походы без состава — как раньше, по пропорции;
|
||||
- окно атаки показывает состав гарнизона и ожидаемый исход: силу сторон, «скорее победа / поражение» и примерные потери. Расчёт тот же, что сделает модуль Боя.
|
||||
|
||||
**Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы.
|
||||
|
||||
В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`.
|
||||
|
||||
**В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену).
|
||||
@@ -64,6 +73,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Бой с типами войск: состав похода и гарнизона, стены у замка и городов, выжившие по типам, ожидаемый исход в окне атаки. Время походов — по `GameClock` (перемотка в DEV проматывает походы). |
|
||||
| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе. В `MarchReturnedEvent` добавлены `Sent`, `TargetName`, `LootText`. |
|
||||
| 19.10.2026 | Claude | Маленькая карта мира (`Meta/World`: WorldSystem, WorldMapPanel, WorldData, WorldLine) удалена, окно `Panel_World` убрано из сцены города. Кнопка «МИР» открывает эту карту. Солдаты из походов старой карты возвращаются в город. |
|
||||
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. |
|
||||
|
||||
Reference in New Issue
Block a user