using System; using System.Collections.Generic; using Bogatyri.World; using UnityEngine; // МОДУЛЬ ГЛОБАЛЬНОГО МИРА: карта и походы. Документ: Docs/Modules/World.md // Живёт между сценами (создаётся сам при запуске игры), поэтому войско идёт и возвращается, // даже пока игрок в городе. // // С другими модулями -- только через контракты и события: // войска -> IArmy (забрать в поход, вернуть выживших) // добыча -> IEconomy // бой -> IBattleSystem (передаётся в WorldSimulation) // сохранение -> сам ISaveParticipant, секция "world" // наружу -> MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent // // Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь. // Позже TryStartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера. public class MarchManager : MonoBehaviour, IWorld, 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; // Время мира -- по часам игры (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; // Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована public WorldSimulation World { get { if (world == null) { world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get()); if (pendingMarches != null) world.LoadMarches(pendingMarches); if (pendingCamps != null) world.LoadCampStates(pendingCamps); pendingMarches = null; pendingCamps = null; } return world; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void CreateInstance() { var go = new GameObject("MarchManager"); DontDestroyOnLoad(go); go.AddComponent(); } void Awake() { Instance = this; GameServices.Register(this); SaveRegistry.Register(this); } void OnDestroy() { if (Instance != this) return; GameServices.Unregister(this); SaveRegistry.Unregister(this); } void Update() { // Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся if (!GameServices.Has() || !GameServices.Has()) return; events.Clear(); World.Advance(NowMs, events); foreach (var e in events) { if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March); else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March); } if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); } // ---------------- Контракт IWorld ---------------- public int ActiveMarches => World.Marches.Count; 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; } 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; } march.units = 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, 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, TargetId = march.targetNodeId, Victory = march.victory, Soldiers = march.soldiers, Survivors = march.survivors }); } // Солдаты и добыча зачисляются, только когда войско дошло до дома 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, Victory = march.victory, Survivors = march.survivors, Sent = march.soldiers, TargetEmpty = march.targetEmpty, TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "", LootText = FormatLoot(march.loot) }); } // ---------------- Сохранение (секция "world") ---------------- [Serializable] class WorldSaveData { public List marches = new List(); public List camps = new List(); // разбитые лагеря и когда они появятся } public string SaveKey => "world"; public string Capture() { var camps = world != null ? world.GetCampStates() : new List(pendingCamps ?? new List()); return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps }); } public void Restore(string json) { WorldSaveData data = JsonUtility.FromJson(json); LoadMarches(data?.marches); if (world != null) world.LoadCampStates(data?.camps); else pendingCamps = data?.camps; } // Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте public void ResetState() { pendingMarches = null; pendingCamps = null; if (world != null) { world.LoadMarches(new List()); world.LoadCampStates(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; } } }