Скорости маршей по типам войск и слоты похода (регламент 31.10)

Мир (Bogatyri.World.Simulation):
- MarchSpeedRules: скорость по типам — пехота 8, лучники 9, кавалерия 12, осадные 4 клетки
  за реальный час (ТЕСТ); марш идёт в реальном времени
- смешанное войско — по самому медленному типу (настройка mixedUsesSlowest: средняя по числу воинов);
  правило для одной армии в регламенте не задано — вопрос команде
- WorldSimulation.TravelTimeMs, AssignUnits: время прихода пересчитывается по составу похода;
  обратный путь — по составу выживших
- MarchRules.GetTravelTimeMs — для похода без состава (как пехота)

Армия (Bogatyri.Army):
- слоты похода: IArmy.MarchSlots (база 2, ТЕСТ), ActiveMarches; TakeForMarch отказывает при занятых слотах;
  походы из старых сохранений сверх лимита дойдут, новых не будет

Таблица MarchConfig (Assets/Resources/MarchConfig.asset): скорости, режим смешанного войска, база слотов.
Карта мира: путь в окне атаки — по составу при выбранном числе воинов, «Все слоты похода заняты»,
верхняя строка «отрядов: 1 из 2».

Тесты: скорости по типам, смешанное войско, обратный путь, слоты; старые тесты мира переведены
на часовые марши. Все 49 тестов ModuleTests проходят вне Unity. Код Валдемара не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-31 16:05:55 +01:00
co-authored by Claude Opus 5.5
parent d9c0039bd2
commit e8b17a7efa
15 changed files with 333 additions and 40 deletions
+20
View File
@@ -0,0 +1,20 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0427eef4612041d2b8a359f9bf358779, type: 3}
m_Name: MarchConfig
m_EditorClassIdentifier:
infantry: 8
archers: 9
cavalry: 12
siege: 4
mixedUsesSlowest: 1
baseMarchSlots: 2
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 99295aa9eabc4ab6bf8cc687f4d89d96
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+5 -1
View File
@@ -22,8 +22,12 @@ public static class GameModules
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
GameServices.Register<IBattleSystem>(new BattleSystem(BattleConfig.Load().ToRules(unitPower)));
// Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig)
var marchConfig = MarchConfig.Load();
MarchManager.SpeedRules = marchConfig.ToSpeedRules();
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService();
var army = new ArmyService(marchConfig.baseMarchSlots);
GameServices.Register<IArmy>(army);
SaveRegistry.Register(army);
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
+55
View File
@@ -0,0 +1,55 @@
using UnityEngine;
// ТАБЛИЦА МАРШЕЙ: скорости войск по типам и слоты похода (регламент 31.10, раздел 7). Числа -- ТЕСТ.
//
// Ассет: Assets/Resources/MarchConfig.asset (выдели его в окне Project и меняй числа в Inspector;
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
// Create -> Bogatyri -> March Config, имя файла -- MarchConfig, папка -- Resources.
//
// Модули таблицу не видят: App/GameModules передаёт скорости в модуль Мира, слоты -- в модуль Армии.
// Бонусы героя (+15%) и технологии (+20%) к скорости появятся вместе с героями и технологиями.
[CreateAssetMenu(fileName = "MarchConfig", menuName = "Bogatyri/March Config")]
public class MarchConfig : ScriptableObject
{
[Header("Скорость: клеток карты за 1 РЕАЛЬНЫЙ час (марш идёт в реальном времени)")]
[Tooltip("Пехота. Регламент 31.10: 8")]
public float infantry = 8f;
[Tooltip("Лучники. Регламент 31.10: 9")]
public float archers = 9f;
[Tooltip("Кавалерия. Регламент 31.10: 12")]
public float cavalry = 12f;
[Tooltip("Осадные. Регламент 31.10: 4")]
public float siege = 4f;
[Tooltip("Смешанное войско идёт со скоростью самого медленного типа. Выключено -- средняя скорость по числу воинов. Правило для одной армии регламент не задаёт -- открытый вопрос")]
public bool mixedUsesSlowest = true;
[Header("Слоты похода")]
[Tooltip("Сколько походов игрока могут идти одновременно. Регламент 31.10: 2 (технология «Военная логистика» -- до 7)")]
public int baseMarchSlots = 2;
static MarchConfig cached;
public static MarchConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<MarchConfig>("MarchConfig");
if (cached == null)
{
Debug.LogWarning("[МАРШИ] Нет Assets/Resources/MarchConfig.asset -- берутся числа по умолчанию из MarchConfig.cs.");
cached = CreateInstance<MarchConfig>();
}
return cached;
}
// Скорости для модуля Мира: милли-единиц карты (1000 = клетка) за реальный час
public Bogatyri.World.MarchSpeedRules ToSpeedRules()
{
return new Bogatyri.World.MarchSpeedRules
{
SpeedMilliPerHour = new[] { Milli(infantry), Milli(archers), Milli(cavalry), Milli(siege) },
MixedUsesSlowest = mixedUsesSlowest
};
}
static int Milli(float cellsPerHour) => Mathf.Max(1, Mathf.RoundToInt(cellsPerHour * 1000f));
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0427eef4612041d2b8a359f9bf358779
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -30,6 +30,17 @@ public sealed class ArmyService : IArmy, ISaveParticipant
public int TypeCount => Types;
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
private readonly int baseMarchSlots;
public ArmyService(int baseMarchSlots = 2)
{
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
}
public int MarchSlots => baseMarchSlots;
public int ActiveMarches => marches.Count;
public int GetTroops(int type)
{
return type >= 0 && type < Types ? troops[type] : 0;
@@ -100,6 +111,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
{
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
if (marches.Exists(m => m.marchId == marchId)) return false;
// Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет
if (marches.Count >= MarchSlots) return false;
// В поход -- только свободные воины, гарнизон остаётся в городе
var free = new int[Types];
@@ -98,8 +98,13 @@ public interface IArmy
void AddTroops(int type, int count); // обучены / вернулись
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
// Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно.
// База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены.
int MarchSlots { get; }
int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот)
// Забрать свободных воинов в поход (запоминает состав похода).
// false -- ничего не взято: не хватает воинов или поход с таким номером уже есть.
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
bool TakeForMarch(int marchId, int count);
// Вернуть выживших из похода по тем же типам войск.
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
+84 -10
View File
@@ -626,7 +626,7 @@ public class ModuleTests
Assert.IsNotNull(a, ea);
var events = new List<WorldEvent>();
world.Advance(10 * 60_000, events); // игра была закрыта 10 минут
world.Advance(7L * 24 * 3_600_000, events); // игра была закрыта неделю (марши идут часами)
Assert.AreEqual(4, events.Count);
for (int i = 1; i < events.Count; i++)
@@ -667,15 +667,88 @@ public class ModuleTests
Assert.AreEqual(0, events.Count, "за миллисекунду до прихода боя нет");
world.Advance(m.arriveMs, events);
Assert.AreEqual(1, events.Count, "бой ровно в момент прихода");
world.Advance(m.returnMs - 1, events);
Assert.AreEqual(1, events.Count);
world.Advance(m.returnMs, events);
Assert.AreEqual(2, events.Count, "возвращение ровно в срок");
Assert.IsTrue(m.victory);
long respawn = m.arriveMs + CampRules.RespawnMs;
Assert.IsTrue(world.IsCampCleared("camp_1", respawn - 1), "за миллисекунду до появления лагерь ещё разбит");
Assert.IsFalse(world.IsCampCleared("camp_1", respawn), "ровно через 30 минут -- снова на месте");
world.Advance(m.returnMs - 1, events);
Assert.AreEqual(1, events.Count);
world.Advance(m.returnMs, events);
Assert.AreEqual(2, events.Count, "возвращение ровно в срок");
}
// Скорости маршей по типам (регламент 31.10, ТЕСТ): пехота 8, лучники 9, кавалерия 12, осадные 4 клетки за реальный час
[Test]
public void World_MarchSpeed_ByTroopTypes()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
MapNode home = world.GetNode(WorldMapLayout.PlayerCityId);
MapNode boss = world.GetNode(WorldMapLayout.BossCastleId);
long d = WorldMapLayout.Distance(home, boss); // в милли-единицах, 1000 = клетка
long Arrive(int[] units)
{
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 1, 0, out _);
Assert.IsTrue(world.AssignUnits(m, units));
world.CancelMarch(m.id);
return m.arriveMs;
}
Assert.AreEqual(d * 3_600_000 / 8000, Arrive(new[] { 10, 0, 0, 0 }), "пехота -- 8 клеток в час");
Assert.AreEqual(d * 3_600_000 / 9000, Arrive(new[] { 0, 10, 0, 0 }), "лучники -- 9");
Assert.AreEqual(d * 3_600_000 / 12000, Arrive(new[] { 0, 0, 10, 0 }), "кавалерия -- 12");
Assert.AreEqual(d * 3_600_000 / 4000, Arrive(new[] { 0, 0, 0, 10 }), "осадные -- 4");
Assert.AreEqual(d * 3_600_000 / 4000, Arrive(new[] { 30, 0, 20, 1 }), "одна осадная машина тормозит всё войско");
Assert.AreEqual(MarchRules.GetTravelTimeMs(home, boss), Arrive(null), "состав неизвестен -- как пехота");
Assert.Greater(d * 3_600_000 / 8000, 3_600_000L, "до замка больше часа -- марш в реальном времени");
// Средняя скорость по числу воинов -- если команда выберет её вместо «по самому медленному»
var weighted = new MarchSpeedRules { MixedUsesSlowest = false };
Assert.AreEqual(10000, weighted.SpeedFor(new[] { 10, 0, 10, 0 }), "(8 + 12) / 2 = 10");
Assert.AreEqual(8000, MarchSpeedRules.Default.SpeedFor(new[] { 10, 0, 10, 0 }), "по самому медленному -- 8");
// После боя время прихода уже не меняется
MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_1", 20, 0, out _);
world.Advance(fought.arriveMs, new List<WorldEvent>());
Assert.IsFalse(world.AssignUnits(fought, new[] { 0, 0, 20, 0 }));
// Обратно идут выжившие со скоростью своего состава
MarchData mixed = world.StartMarch("p", WorldMapLayout.PlayerCityId, "camp_4", 21, 0, out _); // camp_1 уже разбит
world.AssignUnits(mixed, new[] { 20, 0, 0, 1 });
world.Advance(mixed.arriveMs, new List<WorldEvent>());
Assert.AreEqual(world.TravelTimeMs(world.GetNode("camp_4"), home, mixed.survivorUnits), mixed.returnMs - mixed.arriveMs);
}
// Слоты похода (регламент 31.10): база 2, третий поход не уходит, пока один не вернётся
[Test]
public void ArmyService_MarchSlots()
{
var army = new ArmyService(); // база 2
army.AddTroops(0, 30);
Assert.AreEqual(2, army.MarchSlots);
Assert.IsTrue(army.TakeForMarch(1, 5));
Assert.IsTrue(army.TakeForMarch(2, 5));
Assert.AreEqual(2, army.ActiveMarches);
Assert.IsFalse(army.TakeForMarch(3, 5), "оба слота заняты");
Assert.AreEqual(20, army.SoldiersAtHome, "отказ ничего не забрал");
Assert.IsTrue(army.ReturnFromMarch(1, 5));
Assert.IsTrue(army.TakeForMarch(3, 5), "слот освободился");
var bigger = new ArmyService(3); // из таблицы MarchConfig
bigger.AddTroops(0, 30);
for (int i = 1; i <= 3; i++) Assert.IsTrue(bigger.TakeForMarch(i, 5));
Assert.IsFalse(bigger.TakeForMarch(4, 5));
// Старое сохранение с тремя походами: все дойдут, новых не будет, пока не освободится слот
var old = new ArmyService();
old.Restore("troops=10,0,0,0\nmarch=1:1,0,0,0\nmarch=2:1,0,0,0\nmarch=3:1,0,0,0\n");
Assert.AreEqual(3, old.ActiveMarches);
Assert.IsFalse(old.TakeForMarch(4, 1));
Assert.IsTrue(old.ReturnFromMarch(1, 1));
Assert.IsTrue(old.ReturnFromMarch(2, 1));
Assert.IsTrue(old.TakeForMarch(4, 1));
}
// 0 и 1 солдат
@@ -761,20 +834,21 @@ public class ModuleTests
Assert.IsNotNull(first, e1);
Assert.IsNotNull(second, e2);
world.Advance(10 * 60_000, new List<WorldEvent>()); // через 10 минут оба похода закончились
long afterBoth = second.arriveMs; // оба дошли до лагеря (второй -- на 1 мс позже)
world.Advance(afterBoth, new List<WorldEvent>());
Assert.IsTrue(first.victory);
Assert.IsNotEmpty(first.loot);
Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю");
Assert.AreEqual(30, second.survivors);
Assert.IsEmpty(second.loot);
Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000));
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется");
Assert.IsTrue(world.IsCampCleared(camp, afterBoth));
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, afterBoth, out _), "разбитый лагерь не атакуется");
// Сохранение: другой мир с теми же данными помнит разбитый лагерь
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
loaded.LoadCampStates(world.GetCampStates());
Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000));
Assert.IsTrue(loaded.IsCampCleared(camp, afterBoth));
long respawn = first.arriveMs + CampRules.RespawnMs;
Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова");
@@ -791,7 +865,7 @@ public class ModuleTests
Assert.IsNotNull(march, error);
var events = new List<WorldEvent>();
world.Advance(3_600_000, events); // игра была закрыта час
world.Advance(24L * 3_600_000, events); // игра была закрыта сутки (до замка -- больше часа пути)
Assert.AreEqual(2, events.Count, "бой и возвращение за один вызов");
Assert.AreEqual(WorldEventType.BattleResolved, events[0].Type);
+13 -2
View File
@@ -43,7 +43,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
if (world == null)
{
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>());
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>(), SpeedRules);
world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None;
if (pendingMarches != null) world.LoadMarches(pendingMarches);
if (pendingCamps != null) world.LoadCampStates(pendingCamps);
@@ -54,6 +54,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
}
}
// Скорости маршей из таблицы MarchConfig -- их задаёт App/GameModules при запуске (модуль таблицу не видит)
public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void CreateInstance()
{
@@ -143,6 +146,13 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
return false;
}
// Слоты похода (регламент 31.10): база 2, «Военная логистика» -- позже
if (army.ActiveMarches >= army.MarchSlots)
{
error = $"Все слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}). Дождитесь возвращения войска";
return false;
}
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
if (march == null) return false;
@@ -154,7 +164,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
error = "Не удалось собрать войско в поход";
return false;
}
march.units = army.GetMarchUnits(march.id); // состав похода -- для боя с типами войск
// Состав похода -- для боя с типами войск и для скорости: время прихода по самому медленному типу
World.AssignUnits(march, army.GetMarchUnits(march.id));
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
@@ -44,17 +44,50 @@ namespace Bogatyri.World
public MarchState state;
}
// Скорости маршей (регламент 31.10, раздел 7, ТЕСТ). Физический марш идёт в РЕАЛЬНОМ времени (регламент, раздел 2):
// скорость -- единиц карты за 1 реальный час, единица карты = 1 клетка = 1000 милли-единиц координат.
// Время мира -- миллисекунды часов GameClock (реальное время + перемотка DEV).
public sealed class MarchSpeedRules
{
// Милли-единиц карты за реальный час по типам войск (TroopType): пехота 8, лучники 9, кавалерия 12, осадные 4
public int[] SpeedMilliPerHour = { 8000, 9000, 12000, 4000 };
// Смешанное войско: true -- идёт со скоростью самого медленного типа; false -- средняя скорость,
// взвешенная по числу воинов. Регламент правило для одной армии не задаёт -- открытый вопрос команде.
public bool MixedUsesSlowest = true;
public static MarchSpeedRules Default => new MarchSpeedRules();
// Скорость войска данного состава. Состав неизвестен (старый поход) -- как у пехоты.
public long SpeedFor(int[] units)
{
long slowest = long.MaxValue, weighted = 0, count = 0;
for (int i = 0; units != null && i < units.Length && i < SpeedMilliPerHour.Length; i++)
{
if (units[i] <= 0) continue;
long s = Math.Max(1, SpeedMilliPerHour[i]);
slowest = Math.Min(slowest, s);
weighted += s * units[i];
count += units[i];
}
if (count == 0) return Math.Max(1, SpeedMilliPerHour[TroopType.Infantry]);
return MixedUsesSlowest ? slowest : Math.Max(1, weighted / count);
}
// Время в пути в реальных миллисекундах
public long TravelTimeMs(MapNode from, MapNode to, int[] units)
{
return (long)WorldMapLayout.Distance(from, to) * 3_600_000 / SpeedFor(units);
}
}
public static class MarchRules
{
// Единицы (документ 02: у каждого параметра -- единицы): время мира -- РЕАЛЬНЫЕ миллисекунды
// часов GameClock (реальное время + перемотка DEV), ускорения календаря нет -- скорость не утверждена (№7).
// Скорость войска: 400 милли-единиц карты (0,4 клетки) за 1 реальную секунду.
// Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты. ТЕСТ.
public const int SpeedPerSecond = 400;
// Время в пути войска без известного состава (как у пехоты) при скоростях по умолчанию.
// Для настоящего похода -- WorldSimulation.TravelTimeMs: скорости из таблицы MarchConfig и состав войска.
public static long GetTravelTimeMs(MapNode from, MapNode to)
{
return (long)WorldMapLayout.Distance(from, to) * 1000 / SpeedPerSecond;
return MarchSpeedRules.Default.TravelTimeMs(from, to, null);
}
}
@@ -98,11 +131,32 @@ namespace Bogatyri.World
public IReadOnlyList<MapNode> Nodes => nodes;
public IReadOnlyList<MarchData> Marches => marches;
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам
public WorldSimulation(List<MapNode> nodes, IBattleSystem battleSystem)
private readonly MarchSpeedRules speeds;
public MarchSpeedRules Speeds => speeds;
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам.
// Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию.
public WorldSimulation(List<MapNode> nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null)
{
this.nodes = nodes;
this.battleSystem = battleSystem;
this.speeds = speeds ?? MarchSpeedRules.Default;
}
// Время в пути войска данного состава (реальные миллисекунды)
public long TravelTimeMs(MapNode from, MapNode to, int[] units)
{
return speeds.TravelTimeMs(from, to, units);
}
// Состав похода стал известен (Армия забрала воинов): время прихода пересчитывается по скорости состава.
// Только пока войско в пути и ещё не дошло -- после боя время уже не меняется.
public bool AssignUnits(MarchData m, int[] units)
{
if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
m.units = units;
m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units);
return true;
}
// Состояние войска нападающего на момент боя (голод в его поселении, усталость).
@@ -184,7 +238,7 @@ namespace Bogatyri.World
soldiers = soldiers,
survivors = soldiers,
departMs = nowMs,
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits
state = MarchState.Outbound
};
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
@@ -260,7 +314,7 @@ namespace Bogatyri.World
MapNode target = GetNode(m.targetNodeId);
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
m.returnMs = m.arriveMs + TravelTimeMs(target, from, HasUnits(m) ? m.units : null);
m.state = MarchState.Returning;
m.loot = new List<RewardEntry>();
@@ -296,6 +350,8 @@ namespace Bogatyri.World
m.victory = outcome.Victory;
m.survivors = outcome.Survivors;
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава
m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null));
m.defenders = target.garrison;
m.defenderLosses = outcome.DefenderLosses;
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
+39 -12
View File
@@ -89,7 +89,7 @@ public class WorldMapController : MonoBehaviour
sendButton.onClick.AddListener(OnSendPressed);
cancelButton.onClick.AddListener(CloseAttackPanel);
popupOkButton.onClick.AddListener(ShowNextPopup);
soldiersSlider.onValueChanged.AddListener(_ => UpdateSoldiersToSendText());
soldiersSlider.onValueChanged.AddListener(_ => { UpdateSoldiersToSendText(); if (attackPanel.activeSelf) RefreshAttackConditions(); });
attackPanel.SetActive(false);
popupPanel.SetActive(false);
@@ -124,7 +124,8 @@ public class WorldMapController : MonoBehaviour
// Воины, а не отряды: "В походе: 1" читалось как один воин
int inMarches = 0, garrison = 0;
if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; }
soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {World.Marches.Count})";
string slots = army != null ? $"{army.ActiveMarches} из {army.MarchSlots}" : World.Marches.Count.ToString();
soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {slots})";
}
void HookUpComingSoon(Button button, string sectionName)
@@ -287,8 +288,6 @@ public class WorldMapController : MonoBehaviour
{
selectedTarget = target;
int available = GetSoldiersAtHome();
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}"
: target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}"
@@ -296,8 +295,7 @@ public class WorldMapController : MonoBehaviour
attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает
attackInfoText.fontSizeMin = 18;
attackInfoText.fontSizeMax = 32;
attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" +
$"Путь в одну сторону: {travelSeconds} сек" +
attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})" +
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
RefreshAttackConditions();
@@ -307,8 +305,10 @@ public class WorldMapController : MonoBehaviour
soldiersSlider.wholeNumbers = true;
soldiersSlider.value = available;
soldiersSlider.interactable = available > 0;
sendButton.interactable = available > 0;
bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots;
sendButton.interactable = available > 0 && slotFree;
UpdateSoldiersToSendText();
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
attackPanel.SetActive(true);
}
@@ -318,7 +318,37 @@ public class WorldMapController : MonoBehaviour
void RefreshAttackConditions()
{
shownConditions = MarchManager.PlayerConditions();
attackInfoText.text = attackInfoBase + ConditionsWarning(shownConditions);
attackInfoText.text = attackInfoBase + PathText() + ConditionsWarning(shownConditions);
}
// Путь в одну сторону -- по скорости состава (регламент 31.10), реальное время; занятые слоты похода
string PathText()
{
if (selectedTarget == null) return "";
int[] units = PreviewUnits((int)soldiersSlider.value);
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
string slots = GameServices.TryGet(out IArmy army) && army.ActiveMarches >= army.MarchSlots
? $"\n<color=#E06A5A>Все слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}) -- дождитесь возвращения войска</color>"
: "";
return $"\nПуть в одну сторону: {Duration(World.TravelTimeMs(home, selectedTarget, units))}" + slots;
}
// 3 900 000 мс -> "1 ч 5 мин"; меньше минуты -- в секундах
static string Duration(long ms)
{
long sec = (ms + 999) / 1000;
if (sec < 60) return $"{sec} сек";
long min = (sec + 59) / 60;
return min < 60 ? $"{min} мин" : min % 60 == 0 ? $"{min / 60} ч" : $"{min / 60} ч {min % 60} мин";
}
// Кого Армия заберёт в поход: свободных воинов по составу (гарнизон остаётся)
int[] PreviewUnits(int soldiers)
{
if (soldiers <= 0 || !GameServices.TryGet(out IArmy army)) return null;
var troops = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i);
return ArmyMath.TakeProportional(troops, soldiers);
}
void RefreshOpenAttackPanel()
@@ -350,10 +380,7 @@ public class WorldMapController : MonoBehaviour
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) - army.GetGarrison(i); // гарнизон в поход не идёт
int[] units = ArmyMath.TakeProportional(troops, soldiers);
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
if (units == null) return "";
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)