Files
MVP1/Assets/Scripts/Modules/World/MarchManager.cs
T
AlexanderandClaude Opus 5.5 460d1db2de Боевая система с типами войск
Модуль Боя (Bogatyri.Battle, без Unity и float):
- сила стороны по типам войск, сила воина — из GameData.Troops (4/5/9/16)
- контры +25%: пехота > кавалерия > лучники > пехота (по составу врага)
- стены +20% защитникам; осадные +50% против стен, −25% в поле
- потери по типам: проигравший — половина, победитель — до половины (чем ровнее бой, тем больше)
- BattleRequest/BattleOutcome расширены (состав, стены, бонусы, сила сторон); бой «числом» считается пехотой
- формула — предложение, числа константами в BattleSystem.cs, ждёт утверждения командой

Мир и Армия:
- состав гарнизона у каждой цели (WorldMapLayout.GarrisonUnits, из id — карта не изменилась)
- поход хранит состав по типам; выжившие возвращаются точно по типам (IArmy.GetMarchUnits/ReturnUnits)
- время походов по GameClock: перемотка в DEV-панели проматывает и походы

Интерфейс карты:
- окно атаки: состав гарнизона и ожидаемый исход (сила сторон, победа/поражение, потери)
- верхняя панель: воины в походах (а не число отрядов)

Тесты: контры, стены, осадные, потери в пределах состава, состав гарнизонов.
Документы Battle, World, Army, Core, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:34:58 +01:00

237 lines
9.7 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
//
// Точка расширения под мультиплеер: сейчас 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<MarchData> OnMarchStarted;
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
// Время мира -- по часам игры (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; // загружены раньше, чем мир был создан
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
public WorldSimulation World
{
get
{
if (world == null)
{
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>());
if (pendingMarches != null) world.LoadMarches(pendingMarches);
pendingMarches = null;
}
return world;
}
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void CreateInstance()
{
var go = new GameObject("MarchManager");
DontDestroyOnLoad(go);
go.AddComponent<MarchManager>();
}
void Awake()
{
Instance = this;
GameServices.Register<IWorld>(this);
SaveRegistry.Register(this);
}
void OnDestroy()
{
if (Instance != this) return;
GameServices.Unregister<IWorld>(this);
SaveRegistry.Unregister(this);
}
void Update()
{
// Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся
if (!GameServices.Has<IArmy>() || !GameServices.Has<IEconomy>()) 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.SoldiersAtHome < soldiers)
{
error = "Не хватает воинов. Наймите их в Казармах";
return false;
}
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
if (march == null) return false;
// Ушедшие воины недоступны дома, пока не вернутся. Состав похода -- для боя с типами войск
army.TakeForMarch(march.id, soldiers);
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);
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<IArmy>();
if (march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count)
army.ReturnUnits(march.id, march.survivorUnits); // бой посчитал потери по типам
else
army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции
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,
Victory = march.victory,
Survivors = march.survivors,
Sent = march.soldiers,
TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "",
LootText = FormatLoot(march.loot)
});
}
// ---------------- Сохранение (секция "world") ----------------
[Serializable]
class WorldSaveData
{
public List<MarchData> marches = new List<MarchData>();
}
public string SaveKey => "world";
public string Capture()
{
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave() });
}
public void Restore(string json)
{
LoadMarches(JsonUtility.FromJson<WorldSaveData>(json)?.marches);
}
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом)
public void ResetState()
{
pendingMarches = null;
if (world != null) world.LoadMarches(new List<MarchData>());
}
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;
}
}
}