2026-10-19 18:39:28 +02:00
|
|
|
|
using System;
|
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
|
using Bogatyri.World;
|
|
|
|
|
|
using UnityEngine;
|
|
|
|
|
|
|
|
|
|
|
|
// МОДУЛЬ ГЛОБАЛЬНОГО МИРА: карта и походы. Документ: Docs/Modules/World.md
|
|
|
|
|
|
// Живёт между сценами (создаётся сам при запуске игры), поэтому войско идёт и возвращается,
|
|
|
|
|
|
// даже пока игрок в городе.
|
|
|
|
|
|
//
|
|
|
|
|
|
// С другими модулями -- только через контракты и события:
|
|
|
|
|
|
// войска -> IArmy (забрать в поход, вернуть выживших)
|
|
|
|
|
|
// добыча -> IEconomy
|
|
|
|
|
|
// бой -> IBattleSystem (передаётся в WorldSimulation)
|
|
|
|
|
|
// сохранение -> сам ISaveParticipant, секция "world"
|
|
|
|
|
|
// наружу -> MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent
|
2026-10-31 17:05:17 +01:00
|
|
|
|
// разведка -> сам IWorldMap (где армии в момент t), WorldAdvancingEvent перед каждым расчётом
|
2026-10-19 18:39:28 +02:00
|
|
|
|
//
|
|
|
|
|
|
// Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь.
|
|
|
|
|
|
// Позже TryStartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера.
|
2026-10-31 17:05:17 +01:00
|
|
|
|
public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
2026-10-19 18:39:28 +02:00
|
|
|
|
{
|
2026-10-19 19:07:45 +02:00
|
|
|
|
public const string LocalPlayerId = LocalPlayer.Id;
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
|
|
public static MarchManager Instance { get; private set; }
|
|
|
|
|
|
|
|
|
|
|
|
// События для интерфейса карты (с полными данными похода). Модули слушают события EventBus.
|
|
|
|
|
|
public event Action<MarchData> OnMarchStarted;
|
|
|
|
|
|
public event Action<MarchData> OnBattleResolved;
|
|
|
|
|
|
public event Action<MarchData> OnMarchReturned;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
|
2026-11-04 15:21:54 +01:00
|
|
|
|
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение)
|
|
|
|
|
|
public event Action<MarchData> OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
2026-10-27 16:34:58 +01:00
|
|
|
|
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
|
|
|
|
|
|
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
|
|
|
|
|
|
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000;
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
|
|
private readonly List<WorldEvent> events = new List<WorldEvent>();
|
|
|
|
|
|
private WorldSimulation world;
|
|
|
|
|
|
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
|
2026-10-27 16:46:54 +01:00
|
|
|
|
private List<CampState> pendingCamps;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
private ResourcePointsState pendingPoints;
|
2026-10-31 17:05:17 +01:00
|
|
|
|
private PatrolState pendingPatrols;
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
|
|
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
|
|
|
|
|
|
public WorldSimulation World
|
|
|
|
|
|
{
|
|
|
|
|
|
get
|
|
|
|
|
|
{
|
|
|
|
|
|
if (world == null)
|
|
|
|
|
|
{
|
2026-10-31 16:28:50 +01:00
|
|
|
|
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>(), SpeedRules, PointRules);
|
2026-10-30 17:02:05 +01:00
|
|
|
|
world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None;
|
2026-10-31 17:05:17 +01:00
|
|
|
|
world.Patrols = Patrols;
|
|
|
|
|
|
world.LoadPatrolState(pendingPatrols);
|
|
|
|
|
|
pendingPatrols = null;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
if (pendingPoints != null) world.LoadPointsState(pendingPoints); // точки -- до походов: поход к точке ищет её
|
2026-10-19 18:39:28 +02:00
|
|
|
|
if (pendingMarches != null) world.LoadMarches(pendingMarches);
|
2026-10-27 16:46:54 +01:00
|
|
|
|
if (pendingCamps != null) world.LoadCampStates(pendingCamps);
|
2026-10-19 18:39:28 +02:00
|
|
|
|
pendingMarches = null;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
pendingCamps = null;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
pendingPoints = null;
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
return world;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:05:55 +01:00
|
|
|
|
// Скорости маршей из таблицы MarchConfig -- их задаёт App/GameModules при запуске (модуль таблицу не видит)
|
|
|
|
|
|
public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
// Числа ресурсных точек из таблицы WorldResourceConfig -- тоже задаёт App/GameModules
|
|
|
|
|
|
public static ResourcePointRules PointRules = ResourcePointRules.Default;
|
2026-10-31 17:05:17 +01:00
|
|
|
|
// Патрули-заглушки соседей (Разведка М1) из таблицы MarchConfig -- тоже задаёт App/GameModules
|
|
|
|
|
|
public static PatrolRules Patrols = PatrolRules.Default;
|
2026-10-31 16:05:55 +01:00
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
|
|
|
|
|
static void CreateInstance()
|
|
|
|
|
|
{
|
|
|
|
|
|
var go = new GameObject("MarchManager");
|
|
|
|
|
|
DontDestroyOnLoad(go);
|
|
|
|
|
|
go.AddComponent<MarchManager>();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void Awake()
|
|
|
|
|
|
{
|
|
|
|
|
|
Instance = this;
|
|
|
|
|
|
GameServices.Register<IWorld>(this);
|
2026-10-31 17:05:17 +01:00
|
|
|
|
GameServices.Register<IWorldMap>(this);
|
2026-10-19 18:39:28 +02:00
|
|
|
|
SaveRegistry.Register(this);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void OnDestroy()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (Instance != this) return;
|
|
|
|
|
|
GameServices.Unregister<IWorld>(this);
|
2026-10-31 17:05:17 +01:00
|
|
|
|
GameServices.Unregister<IWorldMap>(this);
|
2026-10-19 18:39:28 +02:00
|
|
|
|
SaveRegistry.Unregister(this);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void Update()
|
|
|
|
|
|
{
|
|
|
|
|
|
// Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся
|
|
|
|
|
|
if (!GameServices.Has<IArmy>() || !GameServices.Has<IEconomy>()) return;
|
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
|
long now = NowMs;
|
|
|
|
|
|
CatchUpPopulationBeforeBattles(now);
|
2026-10-31 17:19:39 +01:00
|
|
|
|
// Мир досчитывается по событиям, перед каждым шагом Разведка досчитывает наблюдение до него:
|
|
|
|
|
|
// походы ещё на карте и их выжившие уже известны (после офлайна обратный путь тоже виден)
|
2026-10-19 18:39:28 +02:00
|
|
|
|
events.Clear();
|
2026-10-31 17:19:39 +01:00
|
|
|
|
World.AdvanceInSteps(now, events, t => EventBus.Publish(new WorldAdvancingEvent { ToMs = t }));
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
|
|
foreach (var e in events)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March);
|
|
|
|
|
|
else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March);
|
2026-10-31 16:28:50 +01:00
|
|
|
|
else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March);
|
2026-11-01 13:11:55 +01:00
|
|
|
|
else if (e.Type == WorldEventType.ScoutArrived) HandleScoutArrived(e.March);
|
2026-11-04 15:21:54 +01:00
|
|
|
|
else if (e.Type == WorldEventType.ReconCompleted) HandleReconCompleted(e.March);
|
2026-10-31 16:28:50 +01:00
|
|
|
|
else if (e.Type == WorldEventType.GatherFinished)
|
2026-10-31 16:31:12 +01:00
|
|
|
|
Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой");
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
|
// Состояние войск игрока сейчас: голод -- из модуля Населения. Для окна атаки (прогноз).
|
2026-10-28 19:16:25 +01:00
|
|
|
|
// Усталость пока не выставляется -- правила её учёта не утверждены.
|
|
|
|
|
|
public static BattleCondition PlayerConditions()
|
|
|
|
|
|
{
|
|
|
|
|
|
return GameServices.TryGet(out IPopulation population) && population.IsStarving
|
|
|
|
|
|
? BattleCondition.Famine
|
|
|
|
|
|
: BattleCondition.None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 17:02:05 +01:00
|
|
|
|
// Состояние войск игрока в момент боя (решение №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)
|
2026-10-31 17:05:17 +01:00
|
|
|
|
if (m.ownerId == LocalPlayerId && m.state == MarchState.Outbound && nowMs >= m.arriveMs) { battleDue = true; break; }
|
2026-10-30 17:02:05 +01:00
|
|
|
|
if (battleDue && GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
// ---------------- Контракт IWorld ----------------
|
|
|
|
|
|
|
2026-10-31 17:05:17 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 15:21:54 +01:00
|
|
|
|
// Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять
|
|
|
|
|
|
public bool TryGetReconTarget(string targetId, out ReconTarget target)
|
|
|
|
|
|
{
|
|
|
|
|
|
target = default;
|
|
|
|
|
|
MapNode node = World.GetNode(targetId);
|
|
|
|
|
|
if (node == null) return false;
|
|
|
|
|
|
ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle
|
|
|
|
|
|
: node.type == MapNodeType.NeighborCity ? ReconTargetKind.Settlement
|
|
|
|
|
|
: node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.None;
|
|
|
|
|
|
if (kind == ReconTargetKind.None) return false;
|
|
|
|
|
|
var names = new string[node.rewards.Count];
|
|
|
|
|
|
var amounts = new int[node.rewards.Count];
|
|
|
|
|
|
for (int i = 0; i < node.rewards.Count; i++) { names[i] = node.rewards[i].resource; amounts[i] = node.rewards[i].amount; }
|
|
|
|
|
|
target = new ReconTarget
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = node.id, Name = node.displayName, Kind = kind,
|
|
|
|
|
|
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), HasHero = false, // героев пока нет
|
|
|
|
|
|
ResourceNames = names, ResourceAmounts = amounts
|
|
|
|
|
|
};
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 17:05:17 +01:00
|
|
|
|
// Все армии на карте в момент 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
|
|
public bool TryStartMarch(string targetId, int soldiers, out string error)
|
2026-11-01 13:11:55 +01:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 15:21:54 +01:00
|
|
|
|
// Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки
|
|
|
|
|
|
public bool TryStartRecon(string targetId, int soldiers, int level, out string error)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; }
|
|
|
|
|
|
if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; }
|
|
|
|
|
|
long hold = intel.ReconDurationMs(targetId, level);
|
|
|
|
|
|
if (hold <= 0) { error = "Разведку с отчётом можно отправить на поселение, замок или лагерь"; return false; }
|
|
|
|
|
|
return TryStart(targetId, soldiers, true, out error, level, hold);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0)
|
2026-10-19 18:39:28 +02:00
|
|
|
|
{
|
|
|
|
|
|
if (!GameServices.TryGet(out IArmy army))
|
|
|
|
|
|
{
|
|
|
|
|
|
error = "Нет данных о войске -- запусти игру из города";
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-10-27 16:54:49 +01:00
|
|
|
|
if (army.AvailableForMarch < soldiers)
|
2026-10-19 18:39:28 +02:00
|
|
|
|
{
|
2026-10-27 16:54:49 +01:00
|
|
|
|
error = army.GarrisonTotal > 0
|
|
|
|
|
|
? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»"
|
|
|
|
|
|
: "Не хватает воинов. Наймите их в Казармах";
|
2026-10-19 18:39:28 +02:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:05:55 +01:00
|
|
|
|
// Слоты похода (регламент 31.10): база 2, «Военная логистика» -- позже
|
|
|
|
|
|
if (army.ActiveMarches >= army.MarchSlots)
|
|
|
|
|
|
{
|
|
|
|
|
|
error = $"Все слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}). Дождитесь возвращения войска";
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:11:55 +01:00
|
|
|
|
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
|
2026-10-19 18:39:28 +02:00
|
|
|
|
if (march == null) return false;
|
2026-11-04 15:21:54 +01:00
|
|
|
|
if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs);
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
2026-10-28 18:38:01 +01:00
|
|
|
|
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
|
|
|
|
|
|
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
|
|
|
|
|
|
if (!army.TakeForMarch(march.id, soldiers))
|
|
|
|
|
|
{
|
|
|
|
|
|
World.CancelMarch(march.id);
|
|
|
|
|
|
error = "Не удалось собрать войско в поход";
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-11-01 13:05:57 +01:00
|
|
|
|
// Состав похода -- для боя с типами войск и для скорости: время прихода по средней скорости состава
|
2026-10-31 16:05:55 +01:00
|
|
|
|
World.AssignUnits(march, army.GetMarchUnits(march.id));
|
2026-10-19 18:39:28 +02:00
|
|
|
|
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
|
|
|
|
|
|
2026-11-01 13:11:55 +01:00
|
|
|
|
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}");
|
2026-10-19 18:39:28 +02:00
|
|
|
|
OnMarchStarted?.Invoke(march);
|
2026-11-01 13:11:55 +01:00
|
|
|
|
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
|
2026-11-04 15:21:54 +01:00
|
|
|
|
if (reconLevel > 0)
|
|
|
|
|
|
EventBus.Publish(new ReconStartedEvent { EventId = ReportId(march), TargetId = targetId, Level = reconLevel, TimeMs = march.departMs });
|
2026-10-19 18:39:28 +02:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 15:21:54 +01:00
|
|
|
|
static string ReportId(MarchData march) => "recon_" + march.id;
|
|
|
|
|
|
|
|
|
|
|
|
// Наблюдение закончено: Разведка составляет отчёт по цели на этот момент (один раз на поход)
|
|
|
|
|
|
void HandleReconCompleted(MarchData march)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (GameServices.TryGet(out IIntelligence intel))
|
|
|
|
|
|
intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs);
|
|
|
|
|
|
Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}");
|
|
|
|
|
|
OnReconCompleted?.Invoke(march);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
void HandleBattleResolved(MarchData march)
|
|
|
|
|
|
{
|
2026-11-04 15:16:43 +01:00
|
|
|
|
// Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами
|
|
|
|
|
|
if (Sum(march.missingUnits) > 0 && GameServices.TryGet(out IArmy army))
|
|
|
|
|
|
{
|
|
|
|
|
|
army.MarkMissing(march.id, march.missingUnits, march.arriveMs, Math.Max(0, march.returnMs - march.arriveMs));
|
|
|
|
|
|
army.ProcessMissing(NowMs);
|
|
|
|
|
|
}
|
2026-10-19 18:39:28 +02:00
|
|
|
|
MapNode target = World.GetNode(march.targetNodeId);
|
2026-10-27 16:46:54 +01:00
|
|
|
|
if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя");
|
|
|
|
|
|
else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
|
2026-10-19 18:39:28 +02:00
|
|
|
|
$"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}.");
|
|
|
|
|
|
OnBattleResolved?.Invoke(march);
|
|
|
|
|
|
EventBus.Publish(new MarchBattleResolvedEvent
|
|
|
|
|
|
{
|
|
|
|
|
|
MarchId = march.id,
|
2026-10-30 17:11:15 +01:00
|
|
|
|
BattleId = march.battleSeed,
|
|
|
|
|
|
TimeMs = march.arriveMs,
|
2026-10-19 18:39:28 +02:00
|
|
|
|
TargetId = march.targetNodeId,
|
|
|
|
|
|
Victory = march.victory,
|
|
|
|
|
|
Soldiers = march.soldiers,
|
|
|
|
|
|
Survivors = march.survivors
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-31 16:28:50 +01:00
|
|
|
|
void HandleGatherStarted(MarchData march)
|
|
|
|
|
|
{
|
2026-10-31 16:31:12 +01:00
|
|
|
|
string target = World.TargetName(march);
|
|
|
|
|
|
if (march.targetEmpty) Debug.Log($"[СБОР] {target}: точки уже нет или она занята -- войско возвращается");
|
|
|
|
|
|
else Debug.Log($"[СБОР] {march.soldiers} воинов начали сбор: {target}, унесут {march.cargo}");
|
2026-10-31 16:28:50 +01:00
|
|
|
|
OnGatherStarted?.Invoke(march);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:11:55 +01:00
|
|
|
|
void HandleScoutArrived(MarchData march)
|
|
|
|
|
|
{
|
|
|
|
|
|
string target = World.TargetName(march);
|
|
|
|
|
|
Debug.Log(march.targetEmpty
|
|
|
|
|
|
? $"[РАЗВЕДКА] {target}: цели уже нет -- войско возвращается"
|
|
|
|
|
|
: $"[РАЗВЕДКА] Войско дошло до цели: {target}, возвращается домой");
|
|
|
|
|
|
OnScoutArrived?.Invoke(march);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
// Солдаты и добыча зачисляются, только когда войско дошло до дома
|
|
|
|
|
|
void HandleMarchReturned(MarchData march)
|
|
|
|
|
|
{
|
2026-10-27 16:34:58 +01:00
|
|
|
|
IArmy army = GameServices.Get<IArmy>();
|
2026-10-28 18:38:01 +01:00
|
|
|
|
bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count
|
2026-11-01 13:32:25 +01:00
|
|
|
|
? army.ReturnUnits(march.id, march.survivorUnits, march.woundedUnits, march.returnMs) // по типам; раненые -- в госпиталь
|
2026-10-28 18:38:01 +01:00
|
|
|
|
: army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции
|
|
|
|
|
|
|
|
|
|
|
|
// Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи
|
|
|
|
|
|
if (!returned)
|
|
|
|
|
|
{
|
|
|
|
|
|
Debug.LogWarning($"[ПОХОД] Поход {march.id} неизвестен Армии -- воины и добыча не зачислены");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-19 18:39:28 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-10-23 18:33:04 +02:00
|
|
|
|
EventBus.Publish(new MarchReturnedEvent
|
|
|
|
|
|
{
|
|
|
|
|
|
MarchId = march.id,
|
2026-10-30 17:11:15 +01:00
|
|
|
|
TimeMs = march.returnMs,
|
2026-10-23 18:33:04 +02:00
|
|
|
|
Victory = march.victory,
|
|
|
|
|
|
Survivors = march.survivors,
|
|
|
|
|
|
Sent = march.soldiers,
|
2026-10-27 16:46:54 +01:00
|
|
|
|
TargetEmpty = march.targetEmpty,
|
2026-10-31 16:28:50 +01:00
|
|
|
|
IsGather = march.gather,
|
2026-11-01 13:11:55 +01:00
|
|
|
|
IsScout = march.scout,
|
2026-11-01 13:32:25 +01:00
|
|
|
|
Wounded = Sum(march.woundedUnits),
|
2026-11-04 15:16:43 +01:00
|
|
|
|
Missing = Sum(march.missingUnits),
|
2026-10-31 16:31:12 +01:00
|
|
|
|
TargetName = World.TargetName(march),
|
2026-10-23 18:33:04 +02:00
|
|
|
|
LootText = FormatLoot(march.loot)
|
|
|
|
|
|
});
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------- Сохранение (секция "world") ----------------
|
|
|
|
|
|
|
|
|
|
|
|
[Serializable]
|
|
|
|
|
|
class WorldSaveData
|
|
|
|
|
|
{
|
|
|
|
|
|
public List<MarchData> marches = new List<MarchData>();
|
2026-10-27 16:46:54 +01:00
|
|
|
|
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
|
2026-10-31 16:28:50 +01:00
|
|
|
|
public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает
|
2026-10-31 17:05:17 +01:00
|
|
|
|
public PatrolState patrols; // патрули-заглушки: когда следующий
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public string SaveKey => "world";
|
|
|
|
|
|
|
|
|
|
|
|
public string Capture()
|
|
|
|
|
|
{
|
2026-10-27 16:46:54 +01:00
|
|
|
|
var camps = world != null ? world.GetCampStates() : new List<CampState>(pendingCamps ?? new List<CampState>());
|
2026-10-31 16:28:50 +01:00
|
|
|
|
var points = world != null ? world.GetPointsState() : pendingPoints;
|
2026-10-31 17:05:17 +01:00
|
|
|
|
var patrols = world != null ? world.GetPatrolState() : pendingPatrols;
|
|
|
|
|
|
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps, points = points, patrols = patrols });
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void Restore(string json)
|
|
|
|
|
|
{
|
2026-10-27 16:46:54 +01:00
|
|
|
|
WorldSaveData data = JsonUtility.FromJson<WorldSaveData>(json);
|
2026-10-31 16:28:50 +01:00
|
|
|
|
// Точки -- до походов. Старое сохранение без точек: точки появятся при первом расчёте мира
|
|
|
|
|
|
if (world != null) world.LoadPointsState(data?.points);
|
|
|
|
|
|
else pendingPoints = data?.points;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
LoadMarches(data?.marches);
|
|
|
|
|
|
if (world != null) world.LoadCampStates(data?.camps);
|
|
|
|
|
|
else pendingCamps = data?.camps;
|
2026-10-31 17:05:17 +01:00
|
|
|
|
if (world != null) world.LoadPatrolState(data?.patrols);
|
|
|
|
|
|
else pendingPatrols = data?.patrols;
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-27 16:46:54 +01:00
|
|
|
|
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте
|
2026-10-19 18:39:28 +02:00
|
|
|
|
public void ResetState()
|
|
|
|
|
|
{
|
|
|
|
|
|
pendingMarches = null;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
pendingCamps = null;
|
2026-10-31 16:28:50 +01:00
|
|
|
|
pendingPoints = null;
|
2026-10-31 17:05:17 +01:00
|
|
|
|
pendingPatrols = null;
|
2026-10-27 16:46:54 +01:00
|
|
|
|
if (world != null)
|
|
|
|
|
|
{
|
2026-10-31 17:05:17 +01:00
|
|
|
|
world.LoadPatrolState(null);
|
2026-10-27 16:46:54 +01:00
|
|
|
|
world.LoadMarches(new List<MarchData>());
|
|
|
|
|
|
world.LoadCampStates(null);
|
2026-10-31 16:28:50 +01:00
|
|
|
|
world.LoadPointsState(null); // ресурсные точки появятся заново
|
2026-10-27 16:46:54 +01:00
|
|
|
|
}
|
2026-10-19 18:39:28 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 13:32:25 +01:00
|
|
|
|
static int Sum(int[] units)
|
|
|
|
|
|
{
|
|
|
|
|
|
int n = 0;
|
|
|
|
|
|
if (units != null) foreach (var u in units) n += Math.Max(0, u);
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 18:39:28 +02:00
|
|
|
|
// ---------------- ТЕКСТ ДЛЯ 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|