Files
MVP1/Assets/Scripts/Modules/World/MarchManager.cs
T
AlexanderandClaude Opus 5.5 33db41002e Поход «Разведка» (решения 31.10, п. 2): до цели и обратно без боя, сбора и потерь, обычный слот
IWorld.TryStartScout, MarchData.scout, WorldEventType.ScoutArrived; кнопка «Разведка» в окне атаки;
разведка не разбивает лагерь и не занимает точку.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:11:55 +01:00

407 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<MarchData> OnMarchStarted;
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000;
private readonly List<WorldEvent> events = new List<WorldEvent>();
private WorldSimulation world;
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
private List<CampState> pendingCamps;
private ResourcePointsState pendingPoints;
private PatrolState pendingPatrols;
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
public WorldSimulation World
{
get
{
if (world == null)
{
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>(), 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<MarchManager>();
}
void Awake()
{
Instance = this;
GameServices.Register<IWorld>(this);
GameServices.Register<IWorldMap>(this);
SaveRegistry.Register(this);
}
void OnDestroy()
{
if (Instance != this) return;
GameServices.Unregister<IWorld>(this);
GameServices.Unregister<IWorldMap>(this);
SaveRegistry.Unregister(this);
}
void Update()
{
// Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся
if (!GameServices.Has<IArmy>() || !GameServices.Has<IEconomy>()) 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.ScoutArrived) HandleScoutArrived(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<ArmyOnMap> 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)
{
return TryStart(targetId, soldiers, false, out error);
}
// Поход «Разведка» (решения 31.10, п. 2): тот же путь отправки и тот же слот, но без боя и сбора
public bool TryStartScout(string targetId, int soldiers, out string error)
{
return TryStart(targetId, soldiers, true, out error);
}
bool TryStart(string targetId, int soldiers, bool scout, 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, scout);
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} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}");
OnMarchStarted?.Invoke(march);
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
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 HandleScoutArrived(MarchData march)
{
string target = World.TargetName(march);
Debug.Log(march.targetEmpty
? $"[РАЗВЕДКА] {target}: цели уже нет -- войско возвращается"
: $"[РАЗВЕДКА] Войско дошло до цели: {target}, возвращается домой");
OnScoutArrived?.Invoke(march);
}
// Солдаты и добыча зачисляются, только когда войско дошло до дома
void HandleMarchReturned(MarchData march)
{
IArmy army = GameServices.Get<IArmy>();
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<IEconomy>();
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,
IsScout = march.scout,
TargetName = World.TargetName(march),
LootText = FormatLoot(march.loot)
});
}
// ---------------- Сохранение (секция "world") ----------------
[Serializable]
class WorldSaveData
{
public List<MarchData> marches = new List<MarchData>();
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает
public PatrolState patrols; // патрули-заглушки: когда следующий
}
public string SaveKey => "world";
public string Capture()
{
var camps = world != null ? world.GetCampStates() : new List<CampState>(pendingCamps ?? new List<CampState>());
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<WorldSaveData>(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<MarchData>());
world.LoadCampStates(null);
world.LoadPointsState(null); // ресурсные точки появятся заново
}
}
public List<MarchData> GetMarchesForSave()
{
return world != null ? new List<MarchData>(world.Marches) : new List<MarchData>(pendingMarches ?? new List<MarchData>());
}
// Загрузить походы (из сохранения или из старого progress.json). Мир может быть ещё не создан.
public void LoadMarches(List<MarchData> saved)
{
if (world != null) world.LoadMarches(saved);
else pendingMarches = saved;
}
// ---------------- ТЕКСТ ДЛЯ UI ----------------
public static string FormatLoot(List<RewardEntry> loot)
{
if (loot == null || loot.Count == 0) return "нет";
var parts = new List<string>();
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;
}
}
}