using System; using System.Collections.Generic; using Bogatyri.World; using UnityEngine; // МОДУЛЬ ГЛОБАЛЬНОГО МИРА: карта и походы. Документ: Docs/Modules/World.md // Живёт между сценами (создаётся сам при запуске игры), поэтому войско идёт и возвращается, // даже пока игрок в городе. // // С другими модулями -- только через контракты и события: // войска -> IArmy (забрать в поход, вернуть выживших) // добыча -> IEconomy // бой -> IBattleSystem (передаётся в WorldSimulation) // сохранение -> сам ISaveParticipant, секция "world" // наружу -> MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent // разведка -> сам IWorldMap (где армии в момент t), WorldAdvancingEvent перед каждым расчётом // // Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь. // Позже TryStartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера. public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant { public const string LocalPlayerId = LocalPlayer.Id; public static MarchManager Instance { get; private set; } // События для интерфейса карты (с полными данными похода). Модули слушают события EventBus. public event Action OnMarchStarted; public event Action OnBattleResolved; public event Action OnMarchReturned; public event Action OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет) // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000; private readonly List events = new List(); private WorldSimulation world; private List pendingMarches; // загружены раньше, чем мир был создан private List pendingCamps; private ResourcePointsState pendingPoints; private PatrolState pendingPatrols; // Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована public WorldSimulation World { get { if (world == null) { world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get(), SpeedRules, PointRules); world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None; world.Patrols = Patrols; world.LoadPatrolState(pendingPatrols); pendingPatrols = null; if (pendingPoints != null) world.LoadPointsState(pendingPoints); // точки -- до походов: поход к точке ищет её if (pendingMarches != null) world.LoadMarches(pendingMarches); if (pendingCamps != null) world.LoadCampStates(pendingCamps); pendingMarches = null; pendingCamps = null; pendingPoints = null; } return world; } } // Скорости маршей из таблицы MarchConfig -- их задаёт App/GameModules при запуске (модуль таблицу не видит) public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default; // Числа ресурсных точек из таблицы WorldResourceConfig -- тоже задаёт App/GameModules public static ResourcePointRules PointRules = ResourcePointRules.Default; // Патрули-заглушки соседей (Разведка М1) из таблицы MarchConfig -- тоже задаёт App/GameModules public static PatrolRules Patrols = PatrolRules.Default; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void CreateInstance() { var go = new GameObject("MarchManager"); DontDestroyOnLoad(go); go.AddComponent(); } void Awake() { Instance = this; GameServices.Register(this); GameServices.Register(this); SaveRegistry.Register(this); } void OnDestroy() { if (Instance != this) return; GameServices.Unregister(this); GameServices.Unregister(this); SaveRegistry.Unregister(this); } void Update() { // Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся if (!GameServices.Has() || !GameServices.Has()) return; long now = NowMs; CatchUpPopulationBeforeBattles(now); // Мир досчитывается по событиям, перед каждым шагом Разведка досчитывает наблюдение до него: // походы ещё на карте и их выжившие уже известны (после офлайна обратный путь тоже виден) events.Clear(); World.AdvanceInSteps(now, events, t => EventBus.Publish(new WorldAdvancingEvent { ToMs = t })); foreach (var e in events) { if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March); else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March); else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March); else if (e.Type == WorldEventType.GatherFinished) Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой"); } if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); } // Состояние войск игрока сейчас: голод -- из модуля Населения. Для окна атаки (прогноз). // Усталость пока не выставляется -- правила её учёта не утверждены. public static BattleCondition PlayerConditions() { return GameServices.TryGet(out IPopulation population) && population.IsStarving ? BattleCondition.Famine : BattleCondition.None; } // Состояние войск игрока в момент боя (решение №9: голод проверяется на момент боя). // Бой, прошедший пока игра была закрыта, спрашивает историю голода модуля Населения (WasStarvingAt). static BattleCondition PlayerConditionsAt(long unixTime) { return GameServices.TryGet(out IPopulation population) && population.WasStarvingAt(unixTime) ? BattleCondition.Famine : BattleCondition.None; } // Офлайн -- в причинно-следственном порядке (документ 02): прежде чем считать бой, прошедший в прошлом, // просим Население досчитать время до "сейчас" -- иначе в его истории ещё нет голода за эти часы. // Tick -- публичный метод Населения, тот же, что зовёт PopulationTicker; сам модуль не меняется. void CatchUpPopulationBeforeBattles(long nowMs) { bool battleDue = false; foreach (var m in World.Marches) if (m.ownerId == LocalPlayerId && m.state == MarchState.Outbound && nowMs >= m.arriveMs) { battleDue = true; break; } if (battleDue && GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow); } // ---------------- Контракт IWorld ---------------- public int ActiveMarches { get { int n = 0; foreach (var m in World.Marches) if (m.ownerId == LocalPlayerId) n++; return n; } } // ---------------- Контракт IWorldMap (для Разведки) ---------------- public int HalfWidth => WorldMapLayout.HalfWidth; public int HalfHeight => WorldMapLayout.HalfHeight; public bool TryGetSettlement(string ownerId, out int x, out int y) { foreach (var n in World.Nodes) if (n.ownerId == ownerId && (n.type == MapNodeType.PlayerCity || n.type == MapNodeType.NeighborCity)) { x = n.x; y = n.y; return true; } x = y = 0; return false; } // Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера) public void GetArmies(long timeMs, List result) { foreach (var m in World.Marches) { if (!World.TryGetPositionAt(m, timeMs, out int x, out int y, out int toX, out int toY, out int soldiers)) continue; result.Add(new ArmyOnMap { Id = "march_" + m.id, OwnerId = m.ownerId, X = x, Y = y, ToX = toX, ToY = toY, Soldiers = soldiers }); } } public bool TryStartMarch(string targetId, int soldiers, out string error) { if (!GameServices.TryGet(out IArmy army)) { error = "Нет данных о войске -- запусти игру из города"; return false; } if (army.AvailableForMarch < soldiers) { error = army.GarrisonTotal > 0 ? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»" : "Не хватает воинов. Наймите их в Казармах"; 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; // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. // Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал. if (!army.TakeForMarch(march.id, soldiers)) { World.CancelMarch(march.id); error = "Не удалось собрать войско в поход"; return false; } // Состав похода -- для боя с типами войск и для скорости: время прихода по средней скорости состава World.AssignUnits(march, army.GetMarchUnits(march.id)); if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}"); OnMarchStarted?.Invoke(march); EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers }); return true; } void HandleBattleResolved(MarchData march) { MapNode target = World.GetNode(march.targetNodeId); if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя"); else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + $"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}."); OnBattleResolved?.Invoke(march); EventBus.Publish(new MarchBattleResolvedEvent { MarchId = march.id, BattleId = march.battleSeed, TimeMs = march.arriveMs, TargetId = march.targetNodeId, Victory = march.victory, Soldiers = march.soldiers, Survivors = march.survivors }); } void HandleGatherStarted(MarchData march) { string target = World.TargetName(march); if (march.targetEmpty) Debug.Log($"[СБОР] {target}: точки уже нет или она занята -- войско возвращается"); else Debug.Log($"[СБОР] {march.soldiers} воинов начали сбор: {target}, унесут {march.cargo}"); OnGatherStarted?.Invoke(march); } // Солдаты и добыча зачисляются, только когда войско дошло до дома void HandleMarchReturned(MarchData march) { IArmy army = GameServices.Get(); bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count ? army.ReturnUnits(march.id, march.survivorUnits) // бой посчитал потери по типам : army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции // Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи if (!returned) { Debug.LogWarning($"[ПОХОД] Поход {march.id} неизвестен Армии -- воины и добыча не зачислены"); return; } IEconomy economy = GameServices.Get(); foreach (var reward in march.loot) { if (Enum.TryParse(reward.resource, out ResourceType type)) economy.Add(type, reward.amount); else Debug.LogWarning($"[ПОХОД] Неизвестный ресурс награды: {reward.resource}"); } Debug.Log($"[ПОХОД] Войско вернулось: {march.survivors} солдат, добыча: {FormatLoot(march.loot)}"); OnMarchReturned?.Invoke(march); EventBus.Publish(new MarchReturnedEvent { MarchId = march.id, TimeMs = march.returnMs, Victory = march.victory, Survivors = march.survivors, Sent = march.soldiers, TargetEmpty = march.targetEmpty, IsGather = march.gather, TargetName = World.TargetName(march), LootText = FormatLoot(march.loot) }); } // ---------------- Сохранение (секция "world") ---------------- [Serializable] class WorldSaveData { public List marches = new List(); public List camps = new List(); // разбитые лагеря и когда они появятся public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает public PatrolState patrols; // патрули-заглушки: когда следующий } public string SaveKey => "world"; public string Capture() { var camps = world != null ? world.GetCampStates() : new List(pendingCamps ?? new List()); var points = world != null ? world.GetPointsState() : pendingPoints; var patrols = world != null ? world.GetPatrolState() : pendingPatrols; return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps, points = points, patrols = patrols }); } public void Restore(string json) { WorldSaveData data = JsonUtility.FromJson(json); // Точки -- до походов. Старое сохранение без точек: точки появятся при первом расчёте мира if (world != null) world.LoadPointsState(data?.points); else pendingPoints = data?.points; LoadMarches(data?.marches); if (world != null) world.LoadCampStates(data?.camps); else pendingCamps = data?.camps; if (world != null) world.LoadPatrolState(data?.patrols); else pendingPatrols = data?.patrols; } // Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте public void ResetState() { pendingMarches = null; pendingCamps = null; pendingPoints = null; pendingPatrols = null; if (world != null) { world.LoadPatrolState(null); world.LoadMarches(new List()); world.LoadCampStates(null); world.LoadPointsState(null); // ресурсные точки появятся заново } } public List GetMarchesForSave() { return world != null ? new List(world.Marches) : new List(pendingMarches ?? new List()); } // Загрузить походы (из сохранения или из старого progress.json). Мир может быть ещё не создан. public void LoadMarches(List saved) { if (world != null) world.LoadMarches(saved); else pendingMarches = saved; } // ---------------- ТЕКСТ ДЛЯ UI ---------------- public static string FormatLoot(List loot) { if (loot == null || loot.Count == 0) return "нет"; var parts = new List(); foreach (var r in loot) { parts.Add($"+{r.amount} {GetResourceName(r.resource)}"); } return string.Join(", ", parts); } static string GetResourceName(string resource) { switch (resource) { case "Food": return "еды"; case "Wood": return "дерева"; case "Stone": return "камня"; case "Iron": return "металла"; case "Gold": return "золота"; case "Gems": return "кристаллов"; default: return resource; } } }