diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 3d53f41..4459eac 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -13,7 +13,10 @@ public static class GameModules static void Register() { // Боевая система -- чистый C#, сама зарегистрироваться не может - GameServices.Register(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(new BattleSystem(unitPower)); // Армия -- чистый C#, единственное место хранения войск (секция "army") var army = new ArmyService(); diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index 9a798ae..297130f 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -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 }); diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 180a967..0f6c727 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -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; + } } diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index b93e6e6..049efd7 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -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 diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index e78b5fc..55621b1 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -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] diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 53cba61..2eab950 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -27,8 +27,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant public event Action OnBattleResolved; public event Action OnMarchReturned; - // Время мира. В мультиплеере -- время сервера, а не телефона - public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. + // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. + public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000; private readonly List events = new List(); 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().ReturnFromMarch(march.id, march.survivors); + IArmy army = GameServices.Get(); + 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(); foreach (var reward in march.loot) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs index cebc2e5..81fd783 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs @@ -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 nodes, string id) { foreach (var n in nodes) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 46011df..3530bff 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -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 loot = new List(); 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) { diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index e71ea19..1aec58b 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -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(); + 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().Resolve(new BattleRequest + { + AttackerUnits = units, + DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget), + Defenders = selectedTarget.garrison, + DefenderFortified = true + }); + string result = o.Victory ? "скорее победа" : "скорее поражение"; + 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); diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index d6ec890..74f3910 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -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()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт. @@ -69,6 +71,7 @@ march=:<пехота>,<лучники>,<кавалерия>,<о | Дата | Кто | Что | | --- | --- | --- | +| 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` не изменён. | diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index eb292e1..3d2c6ee 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -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` перенесён в ядро. Правила не изменились. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 569cecb..c992c47 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -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`. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index fd43e5d..9585664 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -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. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index a31971e..4b989c5 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -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()`: при сбросе сохранений походы отменяются. |