diff --git a/Assets/Resources/WorldResourceConfig.asset b/Assets/Resources/WorldResourceConfig.asset new file mode 100644 index 0000000..574f0b6 --- /dev/null +++ b/Assets/Resources/WorldResourceConfig.asset @@ -0,0 +1,43 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6ee3759b377140c0b759a4a386327510, type: 3} + m_Name: WorldResourceConfig + m_EditorClassIdentifier: + woodStock: + - 200 + - 600 + - 1500 + stoneStock: + - 150 + - 450 + - 1000 + metalStock: + - 80 + - 250 + - 600 + goldStock: + - 30 + - 100 + - 250 + woodPerGameHour: 2 + stonePerGameHour: 1.5 + metalPerGameHour: 1 + goldPerGameHour: 0.5 + woodCargo: 12 + stoneCargo: 10 + metalCargo: 8 + goldCargo: 5 + lifetimeRealHours: + - 12 + - 24 + - 48 + pointsPerResource: 2 diff --git a/Assets/Resources/WorldResourceConfig.asset.meta b/Assets/Resources/WorldResourceConfig.asset.meta new file mode 100644 index 0000000..dd96579 --- /dev/null +++ b/Assets/Resources/WorldResourceConfig.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b95775cbe52447a6abff99bfeb488500 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index 86624a0..ac6c914 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -25,6 +25,7 @@ public static class GameModules // Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig) var marchConfig = MarchConfig.Load(); MarchManager.SpeedRules = marchConfig.ToSpeedRules(); + MarchManager.PointRules = WorldResourceConfig.Load().ToRules(); // ресурсные точки (регламент 31.10) // Армия -- чистый C#, единственное место хранения войск (секция "army") var army = new ArmyService(marchConfig.baseMarchSlots); diff --git a/Assets/Scripts/App/WorldResourceConfig.cs b/Assets/Scripts/App/WorldResourceConfig.cs new file mode 100644 index 0000000..4c2a05e --- /dev/null +++ b/Assets/Scripts/App/WorldResourceConfig.cs @@ -0,0 +1,74 @@ +using UnityEngine; + +// ТАБЛИЦА РЕСУРСНЫХ ТОЧЕК (регламент 31.10, раздел 7: «Изменять числа через WorldResourceConfig.asset»). Числа -- ТЕСТ. +// +// Ассет: Assets/Resources/WorldResourceConfig.asset (выдели его в окне Project и меняй числа в Inspector; +// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project -> +// Create -> Bogatyri -> World Resource Config, имя файла -- WorldResourceConfig, папка -- Resources. +// +// Модуль Мира таблицу не видит: App/GameModules переводит её в ResourcePointRules. +// Состояние точек (запас, сроки) ведёт Мир; зачисление добычи и складские лимиты -- Экономика (решение №46). +[CreateAssetMenu(fileName = "WorldResourceConfig", menuName = "Bogatyri/World Resource Config")] +public class WorldResourceConfig : ScriptableObject +{ + [Header("Запас точки: малая / средняя / крупная")] + public int[] woodStock = { 200, 600, 1500 }; + public int[] stoneStock = { 150, 450, 1000 }; + public int[] metalStock = { 80, 250, 600 }; + public int[] goldStock = { 30, 100, 250 }; + + [Header("Добыча одного воина за ИГРОВОЙ час (1 игровой час = 5 реальных минут)")] + public float woodPerGameHour = 2f; + public float stonePerGameHour = 1.5f; + public float metalPerGameHour = 1f; + public float goldPerGameHour = 0.5f; + + [Header("Груз одного воина (осадные машины груза не несут)")] + public int woodCargo = 12; + public int stoneCargo = 10; + public int metalCargo = 8; + public int goldCargo = 5; + + [Header("Срок жизни точки, РЕАЛЬНЫХ часов с момента появления: малая / средняя / крупная")] + public int[] lifetimeRealHours = { 12, 24, 48 }; + + [Header("Не задано регламентом -- ТЕСТ")] + [Tooltip("Сколько точек каждого ресурса одновременно на карте")] + public int pointsPerResource = 2; + + static WorldResourceConfig cached; + + public static WorldResourceConfig Load() + { + if (cached != null) return cached; + cached = Resources.Load("WorldResourceConfig"); + if (cached == null) + { + Debug.LogWarning("[РЕСУРСНЫЕ ТОЧКИ] Нет Assets/Resources/WorldResourceConfig.asset -- берутся числа по умолчанию."); + cached = CreateInstance(); + } + return cached; + } + + public Bogatyri.World.ResourcePointRules ToRules() + { + var d = Bogatyri.World.ResourcePointRules.Default; + return new Bogatyri.World.ResourcePointRules + { + Stock = new[] { Three(woodStock, d.Stock[0]), Three(stoneStock, d.Stock[1]), Three(metalStock, d.Stock[2]), Three(goldStock, d.Stock[3]) }, + MiningMilliPerGameHour = new[] { Milli(woodPerGameHour), Milli(stonePerGameHour), Milli(metalPerGameHour), Milli(goldPerGameHour) }, + CargoPerSoldier = new[] { Mathf.Max(0, woodCargo), Mathf.Max(0, stoneCargo), Mathf.Max(0, metalCargo), Mathf.Max(0, goldCargo) }, + LifetimeRealHours = Three(lifetimeRealHours, d.LifetimeRealHours), + PointsPerResource = Mathf.Max(0, pointsPerResource) + }; + } + + static int Milli(float perHour) => Mathf.Max(0, Mathf.RoundToInt(perHour * 1000f)); + + // Ровно три значения (малая / средняя / крупная), иначе -- значения по умолчанию + static int[] Three(int[] values, int[] fallback) + { + if (values == null || values.Length != 3) return (int[])fallback.Clone(); + return new[] { Mathf.Max(1, values[0]), Mathf.Max(1, values[1]), Mathf.Max(1, values[2]) }; + } +} diff --git a/Assets/Scripts/App/WorldResourceConfig.cs.meta b/Assets/Scripts/App/WorldResourceConfig.cs.meta new file mode 100644 index 0000000..5d68ae1 --- /dev/null +++ b/Assets/Scripts/App/WorldResourceConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ee3759b377140c0b759a4a386327510 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 5361055..0235ce6 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -93,7 +93,8 @@ public struct MarchReturnedEvent public bool Victory; public int Survivors; public int Sent; // сколько воинов уходило в поход - public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все + public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все + public bool IsGather; // поход на ресурсную точку (сбор, а не бой) public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа) public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет" } diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index b3a881b..a29c310 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -751,6 +751,160 @@ public class ModuleTests Assert.IsTrue(old.TakeForMarch(4, 1)); } + // ---------------- Мир: ресурсные точки (регламент 31.10, раздел 7) ---------------- + + static ResourcePoint FirstPoint(WorldSimulation world, int kind) + { + foreach (var p in world.ResourcePoints) if (p.active && p.kind == kind) return p; + return null; + } + + static MarchData SendTo(WorldSimulation world, string target, int[] units, long nowMs) + { + int soldiers = 0; + foreach (var u in units) soldiers += u; + MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, target, soldiers, nowMs, out string error); + Assert.IsNotNull(m, error); + Assert.IsTrue(world.AssignUnits(m, units)); + return m; + } + + [Test] + public void World_ResourcePoints_AppearSpreadOut_WithStockAndLifetime() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + var rules = world.PointRules; + Assert.AreEqual(ResourcePointRules.Resources.Length * rules.PointsPerResource, world.ResourcePoints.Count); + foreach (var p in world.ResourcePoints) + { + Assert.IsTrue(p.active); + Assert.AreEqual(rules.Stock[p.kind][p.size], p.stock); + Assert.AreEqual(rules.LifetimeRealHours[p.size] * 3_600_000L, p.expireMs - p.spawnMs, "срок жизни -- реальные часы"); + MapNode node = world.GetNode(p.id); + Assert.AreEqual(MapNodeType.ResourcePoint, node.type); + foreach (var other in world.Nodes) + { + if (other == node) continue; + int min = other.type == MapNodeType.BossCastle ? 4500 : rules.Spacing; + Assert.GreaterOrEqual(WorldMapLayout.Distance(node, other), min - 1, p.id + " слишком близко к " + other.id); + } + } + } + + [Test] + public void World_Gather_FillsCargo_ReturnsWithLoot_InTimeOrder() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + ResourcePoint wood = FirstPoint(world, 0); + int stockBefore = wood.stock; // минимум 200 + + MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0); + var events = new List(); + world.Advance(m.arriveMs, events); + Assert.AreEqual(WorldEventType.GatherStarted, events[0].Type); + Assert.AreEqual(MarchState.Gathering, m.state); + Assert.AreEqual(120, m.cargo, "груз 10 воинов x 12 дерева"); + // 10 воинов x 2 дерева в игровой час = 20 в час -> 120 за 6 игровых часов = 30 реальных минут + Assert.AreEqual(30 * 60_000L, m.gatherEndMs - m.arriveMs); + Assert.IsFalse(world.CanGatherAt(wood.id, m.arriveMs, out _), "точка занята, пока идёт сбор"); + long expectedReturn = m.gatherEndMs + world.TravelTimeMs(world.GetNode(wood.id), world.GetNode(WorldMapLayout.PlayerCityId), m.units); + + events.Clear(); + world.Advance(7L * 24 * 3_600_000, events); // дальше -- игра закрыта + Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type); + Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); + Assert.AreEqual(1, m.loot.Count); + Assert.AreEqual("Wood", m.loot[0].resource); + Assert.AreEqual(120, m.loot[0].amount); + Assert.AreEqual(10, m.survivors, "при сборе боя нет -- все живы"); + Assert.AreEqual(expectedReturn, m.returnMs, "домой -- от конца сбора, со скоростью состава"); + } + + [Test] + public void World_Gather_DepletesPoint_NewOneAppearsElsewhere() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + ResourcePoint gold = FirstPoint(world, 3); + int stock = gold.stock; // 30 / 100 / 250 + + MarchData m = SendTo(world, gold.id, new[] { 60, 0, 0, 0 }, 0); // груз 60 x 5 = 300 -- больше любого запаса + world.Advance(m.arriveMs, new List()); + Assert.AreEqual(stock, m.cargo, "унесут весь запас"); + world.Advance(m.gatherEndMs, new List()); + Assert.IsFalse(gold.active, "точка истощена"); + int activeGold = 0; + foreach (var p in world.ResourcePoints) if (p.active && p.kind == 3) activeGold++; + Assert.AreEqual(world.PointRules.PointsPerResource, activeGold, "новая точка золота появилась в другом месте"); + + world.Advance(m.returnMs, new List()); + Assert.IsNull(world.GetPoint(gold.id), "истощённая точка убрана, когда войско вернулось"); + Assert.IsNull(world.GetNode(gold.id)); + } + + [Test] + public void World_Gather_SiegeCarryNothing_PointGoneBeforeArrival_ExpiresDuringGather() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + MapNode home = world.GetNode(WorldMapLayout.PlayerCityId); + + // Осадные машины груза не несут + ResourcePoint stone = FirstPoint(world, 1); + MarchData siege = SendTo(world, stone.id, new[] { 0, 0, 0, 5 }, 0); + world.Advance(siege.arriveMs, new List()); + Assert.AreEqual(0, siege.cargo); + Assert.AreEqual(siege.arriveMs, siege.gatherEndMs, "нечего нести -- сразу домой"); + + // Точка исчезла до прихода -- возвращение без добычи + ResourcePoint metal = FirstPoint(world, 2); + long travel = world.TravelTimeMs(home, world.GetNode(metal.id), new[] { 5, 0, 0, 0 }); + MarchData late = SendTo(world, metal.id, new[] { 5, 0, 0, 0 }, metal.expireMs - travel + 60_000); + world.Advance(late.arriveMs, new List()); + Assert.IsTrue(late.targetEmpty); + Assert.AreEqual(0, late.cargo); + Assert.IsEmpty(late.loot); + + // Точка исчезает во время сбора -- уносят, что успели + ResourcePoint wood = FirstPoint(world, 0); + long woodTravel = world.TravelTimeMs(home, world.GetNode(wood.id), new[] { 10, 0, 0, 0 }); + MarchData cut = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, wood.expireMs - woodTravel - 15 * 60_000); // 15 реальных минут на сбор + world.Advance(cut.arriveMs, new List()); + Assert.AreEqual(wood.expireMs, cut.gatherEndMs, "сбор закончился вместе с точкой"); + Assert.AreEqual(60, cut.cargo, "15 мин = 3 игровых часа x 20 дерева в час"); + world.Advance(cut.gatherEndMs, new List()); + Assert.IsFalse(wood.active); + Assert.AreEqual(60, cut.loot[0].amount); + } + + [Test] + public void World_Gather_OnePointOneArmy_AndSaveRestore() + { + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + world.Advance(0, new List()); + ResourcePoint wood = FirstPoint(world, 0); + MarchData m = SendTo(world, wood.id, new[] { 10, 0, 0, 0 }, 0); + Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, wood.id, 5, 0, out string busy), "к точке уже идёт войско"); + Assert.IsNotEmpty(busy); + world.Advance(m.arriveMs, new List()); + + // Сохранение посреди сбора: тот же мир после загрузки доводит сбор до того же итога + var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + loaded.LoadPointsState(world.GetPointsState()); + loaded.LoadMarches(new List(world.Marches)); + Assert.AreEqual(world.ResourcePoints.Count, loaded.ResourcePoints.Count); + Assert.IsNotNull(loaded.GetNode(wood.id), "точка восстановлена до походов"); + Assert.AreEqual(1, loaded.Marches.Count, "поход к точке восстановлен"); + var events = new List(); + loaded.Advance(7L * 24 * 3_600_000, events); + Assert.AreEqual(WorldEventType.GatherFinished, events[0].Type, "загруженный мир доводит сбор"); + Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type); + Assert.AreEqual(120, events[1].March.loot[0].amount, "с тем же грузом"); + Assert.AreEqual(0, loaded.Marches.Count); + } + // 0 и 1 солдат [Test] public void World_ZeroAndOneSoldier() diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index fb49502..aa6c792 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -26,6 +26,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant public event Action OnMarchStarted; public event Action OnBattleResolved; public event Action OnMarchReturned; + public event Action OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет) // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. @@ -35,6 +36,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant private WorldSimulation world; private List pendingMarches; // загружены раньше, чем мир был создан private List pendingCamps; + private ResourcePointsState pendingPoints; // Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована public WorldSimulation World @@ -43,12 +45,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { if (world == null) { - world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get(), SpeedRules); + world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get(), SpeedRules, PointRules); world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None; + 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; } @@ -56,6 +60,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant // Скорости маршей из таблицы MarchConfig -- их задаёт App/GameModules при запуске (модуль таблицу не видит) public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default; + // Числа ресурсных точек из таблицы WorldResourceConfig -- тоже задаёт App/GameModules + public static ResourcePointRules PointRules = ResourcePointRules.Default; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void CreateInstance() @@ -93,6 +99,9 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { 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.GetNode(e.March.targetNodeId)?.displayName}), войско идёт домой"); } if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); @@ -193,6 +202,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant }); } + void HandleGatherStarted(MarchData march) + { + MapNode target = World.GetNode(march.targetNodeId); + if (march.targetEmpty) Debug.Log($"[СБОР] {target?.displayName}: точки уже нет или она занята -- войско возвращается"); + else Debug.Log($"[СБОР] {march.soldiers} воинов начали сбор: {target?.displayName}, унесут {march.cargo}"); + OnGatherStarted?.Invoke(march); + } + // Солдаты и добыча зачисляются, только когда войско дошло до дома void HandleMarchReturned(MarchData march) { @@ -227,6 +244,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant Survivors = march.survivors, Sent = march.soldiers, TargetEmpty = march.targetEmpty, + IsGather = march.gather, TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "", LootText = FormatLoot(march.loot) }); @@ -239,6 +257,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { public List marches = new List(); public List camps = new List(); // разбитые лагеря и когда они появятся + public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает } public string SaveKey => "world"; @@ -246,12 +265,16 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant public string Capture() { var camps = world != null ? world.GetCampStates() : new List(pendingCamps ?? new List()); - return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps }); + var points = world != null ? world.GetPointsState() : pendingPoints; + return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps, points = points }); } 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; @@ -262,10 +285,12 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant { pendingMarches = null; pendingCamps = null; + pendingPoints = null; if (world != null) { world.LoadMarches(new List()); world.LoadCampStates(null); + world.LoadPointsState(null); // ресурсные точки появятся заново } } diff --git a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs index 485e88a..e733344 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldMapLayout.cs @@ -8,7 +8,8 @@ namespace Bogatyri.World PlayerCity, // город игрока NeighborCity, // соседний город -- другой игрок BossCastle, // Большой замок с боссом в центре карты - Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова + Camp, // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова + ResourcePoint // ресурсная точка: войско собирает ресурс, точка истощается и исчезает (регламент 31.10) } [Serializable] @@ -184,7 +185,7 @@ namespace Bogatyri.World } // Случайная точка, не слишком близко к замку и к другим точкам - static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y, int spacing = MinCitySpacing) + public static bool TryFindFreeSpot(DeterministicRandom rng, List nodes, out int x, out int y, int spacing = MinCitySpacing) { for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++) { diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs new file mode 100644 index 0000000..2cf9b91 --- /dev/null +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; + +namespace Bogatyri.World +{ + // Ресурсная точка на карте (регламент 31.10, раздел 7; решение №46: состояние точки ведёт World). + // Состояние меняется со временем, поэтому хранится в сохранении, а не строится из seed, как замок и города. + [Serializable] + public class ResourcePoint + { + public string id; + public int kind; // ResourcePointRules.Resources: 0 дерево, 1 камень, 2 металл, 3 золото + public int size; // 0 малая, 1 средняя, 2 крупная + public int x; + public int y; + public int stock; // сколько осталось + public int stockMax; + public long spawnMs; // появилась (реальное время часов GameClock) + public long expireMs; // исчезнет + public int occupiedBy; // номер похода, который сейчас собирает (0 -- свободна) + public bool active; // false -- истощена или исчезла (остаётся, пока к ней идёт или с неё возвращается войско) + } + + // Числа ресурсных точек (регламент 31.10, раздел 7, ТЕСТ). В игре -- из таблицы WorldResourceConfig. + public sealed class ResourcePointRules + { + // Имена ресурсов -- как в ResourceType (Мир про экономику не знает, зачисляет её модуль по имени) + public static readonly string[] Resources = { "Wood", "Stone", "Iron", "Gold" }; + public static readonly string[] KindNames = { "Лес", "Каменоломня", "Рудник", "Золотая жила" }; + public static readonly string[] SizeNames = { "малая", "средняя", "крупная" }; + + // Запас: [ресурс][размер] -- дерево 200/600/1500, камень 150/450/1000, металл 80/250/600, золото 30/100/250 + public int[][] Stock = + { + new[] { 200, 600, 1500 }, + new[] { 150, 450, 1000 }, + new[] { 80, 250, 600 }, + new[] { 30, 100, 250 } + }; + // Добыча одного воина за ИГРОВОЙ час, в тысячных долях: 2 / 1,5 / 1 / 0,5 + public int[] MiningMilliPerGameHour = { 2000, 1500, 1000, 500 }; + // Груз одного воина: 12 / 10 / 8 / 5. Осадные машины груза не несут (регламент) + public int[] CargoPerSoldier = { 12, 10, 8, 5 }; + // Срок жизни точки в РЕАЛЬНЫХ часах с момента появления: малая 12, средняя 24, крупная 48 + public int[] LifetimeRealHours = { 12, 24, 48 }; + // 1 игровой час = 5 реальных минут (регламент 31.10, раздел 2) + public long GameHourMs = 5 * 60 * 1000; + + // Не задано регламентом -- ТЕСТ, решает команда: + public int PointsPerResource = 2; // сколько точек каждого ресурса одновременно на карте + public int Spacing = 2200; // не ближе 2,2 клетки к другим точкам карты + + public static ResourcePointRules Default => new ResourcePointRules(); + + // Сбор с начала startMs: сколько войско унесёт и когда закончит. + // Конец -- груз полон, точка истощена или исчезла (expireMs), что раньше. + public void Gather(int kind, int[] units, int stock, long startMs, long expireMs, out int amount, out long endMs) + { + int miners = 0, carriers = 0; + for (int i = 0; units != null && i < units.Length; i++) + { + if (units[i] <= 0) continue; + miners += units[i]; + if (i != TroopType.Siege) carriers += units[i]; + } + long target = Math.Min(Math.Max(0, stock), (long)carriers * CargoPerSoldier[kind]); + long rate = (long)miners * MiningMilliPerGameHour[kind]; // тысячных долей за игровой час + if (target <= 0 || rate <= 0 || expireMs <= startMs) + { + amount = 0; + endMs = startMs; + return; + } + long full = (target * 1000 * GameHourMs + rate - 1) / rate; // время до полного груза (вверх) + if (startMs + full <= expireMs) + { + amount = (int)target; + endMs = startMs + full; + } + else + { + endMs = expireMs; // точка исчезла раньше -- уносят, что успели + amount = (int)Math.Min(target, rate * (expireMs - startMs) / (1000 * GameHourMs)); + } + } + } + + // Состояние ресурсных точек для сохранения (секция "world") + [Serializable] + public class ResourcePointsState + { + public List points = new List(); + public int nextPointId = 1; + public int generation; // сколько точек уже появилось -- для детерминированного места следующей + public bool initialized; // первые точки уже выставлены + } + + public sealed partial class WorldSimulation + { + private readonly ResourcePointRules pointRules; + private readonly int pointSeed; + private readonly List points = new List(); + private int nextPointId = 1; + private int pointGeneration; + private bool pointsInitialized; + + public ResourcePointRules PointRules => pointRules; + public IReadOnlyList ResourcePoints => points; + + public ResourcePoint GetPoint(string id) + { + return points.Find(p => p.id == id); + } + + // Первые точки: по PointsPerResource каждого ресурса, появляются в момент первого расчёта мира + void EnsureResourcePoints(long nowMs) + { + if (pointsInitialized) return; + pointsInitialized = true; + for (int kind = 0; kind < ResourcePointRules.Resources.Length; kind++) + for (int k = 0; k < pointRules.PointsPerResource; k++) + SpawnPoint(kind, nowMs); + } + + // Новая точка того же ресурса в другом месте. Место и размер -- из seed и номера появления (одинаково на сервере) + ResourcePoint SpawnPoint(int kind, long nowMs) + { + pointGeneration++; + var rng = new DeterministicRandom(pointSeed ^ 0x2F0E1D ^ pointGeneration * 7919); + if (!WorldMapLayout.TryFindFreeSpot(rng, nodes, out int x, out int y, pointRules.Spacing)) return null; + + int size = rng.Range(0, 3); // размер равновероятно -- регламент не задаёт (ТЕСТ) + var p = new ResourcePoint + { + id = "res_" + nextPointId++, + kind = kind, + size = size, + x = x, + y = y, + stock = pointRules.Stock[kind][size], + stockMax = pointRules.Stock[kind][size], + spawnMs = nowMs, + expireMs = nowMs + pointRules.LifetimeRealHours[size] * 3_600_000L, + active = true + }; + points.Add(p); + nodes.Add(ToNode(p)); + return p; + } + + static MapNode ToNode(ResourcePoint p) + { + return new MapNode + { + id = p.id, + displayName = $"{ResourcePointRules.KindNames[p.kind]} ({ResourcePointRules.SizeNames[p.size]})", + type = MapNodeType.ResourcePoint, + x = p.x, + y = p.y, + canBeAttacked = true, // «можно отправить войско» + level = p.size + 1 + }; + } + + // Точка истощена или исчезла: на её месте больше нечего собирать, новая появляется в другом месте + void RetirePoint(ResourcePoint p, long atMs) + { + if (!p.active) return; + p.active = false; + p.occupiedBy = 0; + SpawnPoint(p.kind, atMs); + } + + // Исчезнувшие точки убираются с карты, когда к ним уже никто не идёт и с них никто не возвращается + void PurgeRetiredPoints() + { + points.RemoveAll(p => + { + if (p.active || marches.Exists(m => m.targetNodeId == p.id)) return false; + nodes.RemoveAll(n => n.id == p.id); + return true; + }); + } + + public bool CanGatherAt(string pointId, long nowMs, out string error) + { + ResourcePoint p = GetPoint(pointId); + if (p == null || !p.active || nowMs >= p.expireMs) { error = "Этой ресурсной точки больше нет"; return false; } + if (p.occupiedBy != 0) { error = "Точку уже занимает войско"; return false; } + if (marches.Exists(m => m.targetNodeId == pointId && m.state == MarchState.Outbound)) + { + error = "К этой точке уже идёт войско"; + return false; + } + error = null; + return true; + } + + // Прогноз сбора для окна отправки: сколько унесут и сколько продлится сбор, если дойдут в arriveMs + public void PreviewGather(string pointId, int[] units, long arriveMs, out int amount, out long gatherMs) + { + ResourcePoint p = GetPoint(pointId); + if (p == null) { amount = 0; gatherMs = 0; return; } + pointRules.Gather(p.kind, units, p.stock, arriveMs, p.expireMs, out amount, out long endMs); + gatherMs = endMs - arriveMs; + } + + // Войско дошло до точки: начинает сбор. Точки нет или она занята -- возвращается без добычи + void StartGather(MarchData m) + { + MapNode from = GetNode(m.fromNodeId); + MapNode target = GetNode(m.targetNodeId); + ResourcePoint p = GetPoint(m.targetNodeId); + int[] units = HasUnits(m) ? m.units : new[] { m.soldiers, 0, 0, 0 }; + + m.victory = false; + m.survivors = m.soldiers; // при сборе боя нет -- все живы + m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null; + m.loot = new List(); + + if (p == null || !p.active || p.occupiedBy != 0 || m.arriveMs >= p.expireMs) + { + m.targetEmpty = true; + m.gatherEndMs = m.arriveMs; + m.returnMs = m.arriveMs + TravelTimeMs(target, from, units); + m.state = MarchState.Returning; + return; + } + + pointRules.Gather(p.kind, units, p.stock, m.arriveMs, p.expireMs, out int amount, out long endMs); + m.cargo = amount; + m.gatherEndMs = endMs; + m.state = MarchState.Gathering; + p.occupiedBy = m.id; + } + + // Сбор закончен: груз -- в добычу похода (зачислит Экономика по возвращении), войско идёт домой + void FinishGather(MarchData m) + { + MapNode from = GetNode(m.fromNodeId); + MapNode target = GetNode(m.targetNodeId); + ResourcePoint p = GetPoint(m.targetNodeId); + int[] units = HasUnits(m) ? m.units : null; + + if (p != null) + { + p.stock = Math.Max(0, p.stock - m.cargo); + if (p.occupiedBy == m.id) p.occupiedBy = 0; + if (m.cargo > 0) m.loot.Add(new RewardEntry { resource = ResourcePointRules.Resources[p.kind], amount = m.cargo }); + if (p.stock <= 0 || m.gatherEndMs >= p.expireMs) RetirePoint(p, m.gatherEndMs); + } + m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units); + m.state = MarchState.Returning; + } + + // ---------------- Сохранение ---------------- + + public ResourcePointsState GetPointsState() + { + var state = new ResourcePointsState + { + nextPointId = nextPointId, + generation = pointGeneration, + initialized = pointsInitialized + }; + foreach (var p in points) state.points.Add(p); + return state; + } + + // Загружать ДО походов: поход к точке восстанавливается, только если точка есть на карте + public void LoadPointsState(ResourcePointsState state) + { + nodes.RemoveAll(n => n.type == MapNodeType.ResourcePoint); + points.Clear(); + nextPointId = state?.nextPointId ?? 1; + pointGeneration = state?.generation ?? 0; + pointsInitialized = state?.initialized ?? false; + if (state?.points == null) return; + foreach (var p in state.points) + { + if (p == null || p.kind < 0 || p.kind >= ResourcePointRules.Resources.Length || p.size < 0 || p.size > 2) continue; + points.Add(p); + nodes.Add(ToNode(p)); + } + } + } +} diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs.meta b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs.meta new file mode 100644 index 0000000..4ad6a1d --- /dev/null +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Points.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: caa1c535b9b8440c95d48f1928e6b78f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 8836643..43eb521 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -7,7 +7,8 @@ namespace Bogatyri.World { Outbound, // идут к цели Returning, // бой прошёл, возвращаются домой - Finished // вернулись, солдаты и добыча зачислены + Finished, // вернулись, солдаты и добыча зачислены + Gathering // стоят на ресурсной точке и собирают (регламент 31.10) } // Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд": @@ -23,7 +24,10 @@ namespace Bogatyri.World public int soldiers; // сколько ушло public int survivors; // сколько возвращается (известно после боя) public bool victory; - public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются + public bool targetEmpty; // лагерь уже разбит / ресурсная точка исчезла или занята: боя и сбора нет, все возвращаются + public bool gather; // поход на ресурсную точку: сбор вместо боя + public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) + public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя public int defenderLosses; // сколько потерял защитник public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой @@ -108,7 +112,9 @@ namespace Bogatyri.World public enum WorldEventType { BattleResolved, // войско дошло, бой прошёл - MarchReturned // войско вернулось домой + MarchReturned, // войско вернулось домой + GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty) + GatherFinished // сбор закончен, войско идёт домой с грузом } public struct WorldEvent @@ -120,7 +126,7 @@ namespace Bogatyri.World // Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере, // а клиент только показывает то, что он вернул. - public sealed class WorldSimulation + public sealed partial class WorldSimulation { private readonly List nodes; private readonly IBattleSystem battleSystem; @@ -136,11 +142,14 @@ namespace Bogatyri.World // Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам. // Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию. - public WorldSimulation(List nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null) + public WorldSimulation(List nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null, + ResourcePointRules pointRules = null, int seed = WorldMapLayout.DefaultSeed) { this.nodes = nodes; this.battleSystem = battleSystem; this.speeds = speeds ?? MarchSpeedRules.Default; + this.pointRules = pointRules ?? ResourcePointRules.Default; + this.pointSeed = seed; } // Время в пути войска данного состава (реальные миллисекунды) @@ -228,6 +237,7 @@ namespace Bogatyri.World return null; } if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; } + if (target.type == MapNodeType.ResourcePoint && !CanGatherAt(targetId, nowMs, out error)) return null; var march = new MarchData { @@ -239,7 +249,8 @@ namespace Bogatyri.World survivors = soldiers, departMs = nowMs, arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits - state = MarchState.Outbound + state = MarchState.Outbound, + gather = target.type == MapNodeType.ResourcePoint }; // Номер боя -- из данных похода: тот же поход всегда даёт тот же бой. // В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный. @@ -264,33 +275,64 @@ namespace Bogatyri.World // может и дойти, и вернуться -- события придут в правильном порядке. public void Advance(long nowMs, List events) { + EnsureResourcePoints(nowMs); // первая игра: ресурсные точки появляются на карте + // События -- строго по времени (документ 02: офлайн в причинно-следственном порядке). - // Каждый раз берётся самое раннее: приход войска к цели (бой) или возвращение домой. - // Бой назначает время возвращения, поэтому возвращение того же похода может оказаться в этом же вызове. - // Кто первым дошёл до лагеря, тот его и разбил. При равном времени -- бой раньше возвращения, затем по номеру похода. + // Каждый раз берётся самое раннее: приход к цели (бой или начало сбора), конец сбора, + // исчезновение ресурсной точки или возвращение домой. Событие назначает следующее время похода, + // поэтому за один вызов поход может дойти, собрать и вернуться. Кто первым дошёл до лагеря или точки -- + // того и цель. При равном времени: приход, конец сбора, исчезновение точки, возвращение; затем по номеру. while (true) { MarchData next = null; + ResourcePoint expiring = null; long nextMs = 0; - bool nextIsBattle = false; + int nextKind = int.MaxValue; + int nextId = int.MaxValue; foreach (var m in marches) { - bool battle = m.state == MarchState.Outbound; - if (!battle && m.state != MarchState.Returning) continue; - long at = battle ? m.arriveMs : m.returnMs; + int kind; + long at; + if (m.state == MarchState.Outbound) { kind = 0; at = m.arriveMs; } + else if (m.state == MarchState.Gathering) { kind = 1; at = m.gatherEndMs; } + else if (m.state == MarchState.Returning) { kind = 3; at = m.returnMs; } + else continue; if (at > nowMs) continue; - if (next == null || at < nextMs || at == nextMs && (battle && !nextIsBattle || battle == nextIsBattle && m.id < next.id)) + if (next == null && expiring == null || at < nextMs || at == nextMs && (kind < nextKind || kind == nextKind && m.id < nextId)) { - next = m; nextMs = at; nextIsBattle = battle; + next = m; expiring = null; nextMs = at; nextKind = kind; nextId = m.id; } } - if (next == null) break; + foreach (var p in points) + { + // Точка без войска исчезает в срок (с войском -- её срок уже учтён в конце сбора) + if (!p.active || p.occupiedBy != 0 || p.expireMs > nowMs) continue; + if (next == null && expiring == null || p.expireMs < nextMs || p.expireMs == nextMs && 2 < nextKind) + { + next = null; expiring = p; nextMs = p.expireMs; nextKind = 2; nextId = int.MaxValue; + } + } + if (next == null && expiring == null) break; - if (nextIsBattle) + if (expiring != null) + { + RetirePoint(expiring, nextMs); + } + else if (nextKind == 0 && next.gather) + { + StartGather(next); + events.Add(new WorldEvent { Type = WorldEventType.GatherStarted, March = next, TimeMs = nextMs }); + } + else if (nextKind == 0) { ResolveBattle(next); events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = next, TimeMs = nextMs }); } + else if (nextKind == 1) + { + FinishGather(next); + events.Add(new WorldEvent { Type = WorldEventType.GatherFinished, March = next, TimeMs = nextMs }); + } else { next.state = MarchState.Finished; @@ -299,6 +341,7 @@ namespace Bogatyri.World } marches.RemoveAll(m => m.state == MarchState.Finished); + PurgeRetiredPoints(); // Лагеря, которые уже появились снова, больше не нужно помнить expiredCamps.Clear(); @@ -400,11 +443,19 @@ namespace Bogatyri.World start = m.departMs; end = m.arriveMs; } + else if (m.state == MarchState.Gathering) + { + // Войско стоит на точке и собирает + segmentFrom = target; + segmentTo = home; + progress = 0; + return; + } else { segmentFrom = target; segmentTo = home; - start = m.arriveMs; + start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора end = m.returnMs; } diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs index 5820030..a64680b 100644 --- a/Assets/Scripts/UI/MarchReturnPopup.cs +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -31,8 +31,10 @@ public static class MarchReturnPopup showing = true; MarchReturnedEvent e = queue.Dequeue(); - string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»"; - string result = e.TargetEmpty ? "лагерь уже был разбит, боя не было" + string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : (e.IsGather ? "Сбор" : "Поход на") + $" «{e.TargetName}»"; + string result = e.IsGather + ? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "ресурсы собраны") + : e.TargetEmpty ? "лагерь уже был разбит, боя не было" : e.Victory ? "победа" : "поражение"; int lost = Mathf.Max(0, e.Sent - e.Survivors); diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index ba8b876..7ac47c4 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -119,9 +119,16 @@ public class MarchView : MonoBehaviour RebuildSquares(soldiers, returning ? ReturningColor : OutboundColor); } - long endMs = returning ? march.returnMs : march.arriveMs; - int secondsLeft = Mathf.Max(0, Mathf.CeilToInt((endMs - nowMs) / 1000f)); - string direction = returning ? "домой" : "в бой"; - label.text = $"{soldiers} сол. {direction} {secondsLeft / 60}:{secondsLeft % 60:00}"; + bool gathering = march.state == MarchState.Gathering; + long endMs = returning ? march.returnMs : gathering ? march.gatherEndMs : march.arriveMs; + long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000); + string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой") + : gathering ? $"сбор {march.cargo}" + : march.gather ? "на сбор" : "в бой"; + // Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30» + string time = secondsLeft >= 3600 + ? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}" + : $"{secondsLeft / 60}:{secondsLeft % 60:00}"; + label.text = $"{soldiers} сол. {direction} {time}"; } } diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index eed0c0d..a4e2e73 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -65,6 +65,19 @@ public class WorldMapController : MonoBehaviour // Лагеря меняют вид: разбит -- серый, с таймером до появления private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>(); + + // Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры + private readonly Dictionary pointViews = new Dictionary(); + private readonly List stalePoints = new List(); + const float PointSize = 0.7f; + static readonly Color[] PointColors = + { + new Color(0.2f, 0.55f, 0.2f), // дерево + new Color(0.6f, 0.6f, 0.62f), // камень + new Color(0.35f, 0.45f, 0.6f), // металл + new Color(0.95f, 0.78f, 0.2f) // золото + }; + static readonly string[] ResourceGenitive = { "дерева", "камня", "металла", "золота" }; private float nextCampRefresh; // Окно атаки: постоянная часть текста и состояние войска, с которым оно показано @@ -104,6 +117,7 @@ public class WorldMapController : MonoBehaviour { if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved += HandleBattleResolved; + MarchManager.Instance.OnGatherStarted += HandleGatherStarted; MarchManager.Instance.OnMarchReturned += HandleMarchReturned; } @@ -111,6 +125,7 @@ public class WorldMapController : MonoBehaviour { if (MarchManager.Instance == null) return; MarchManager.Instance.OnBattleResolved -= HandleBattleResolved; + MarchManager.Instance.OnGatherStarted -= HandleGatherStarted; MarchManager.Instance.OnMarchReturned -= HandleMarchReturned; } @@ -231,6 +246,7 @@ public class WorldMapController : MonoBehaviour nextCampRefresh = Time.unscaledTime + 1f; long now = MarchManager.NowMs; + RefreshPoints(now); foreach (var (node, sprite, label) in campViews) { long left = World.CampRespawnInMs(node.id, now); @@ -241,6 +257,43 @@ public class WorldMapController : MonoBehaviour } } + // Ресурсные точки: ромб цвета ресурса, подпись -- запас и сколько осталось жить; занятая -- «идёт сбор» + void RefreshPoints(long now) + { + foreach (var p in World.ResourcePoints) + { + if (!p.active) continue; + if (!pointViews.TryGetValue(p.id, out var view)) + { + MapNode node = World.GetNode(p.id); + Vector3 pos = ToWorld(node); + var sprite = CreateSprite(p.id, PrimitiveSprites.Square, PointColors[p.kind], pos, Vector3.one * PointSize, 0); + sprite.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб -- не путается с городом и лагерем + var label = CreateLabel("", pos - new Vector3(0, PointSize / 2f + 0.5f, 0), 2.2f); + view = (sprite, label); + pointViews[p.id] = view; + } + string name = World.GetNode(p.id).displayName; + view.label.text = p.occupiedBy != 0 + ? $"{name}\nидёт сбор" + : $"{name}\n{p.stock} {ResourceGenitive[p.kind]}, ещё {Duration(System.Math.Max(0, p.expireMs - now))}"; + } + + // Исчезнувшие точки -- с карты + stalePoints.Clear(); + foreach (var id in pointViews.Keys) + { + ResourcePoint p = World.GetPoint(id); + if (p == null || !p.active) stalePoints.Add(id); + } + foreach (var id in stalePoints) + { + Destroy(pointViews[id].sprite.gameObject); + Destroy(pointViews[id].label.gameObject); + pointViews.Remove(id); + } + } + static string FormatMinutes(long ms) { long sec = (ms + 999) / 1000; @@ -266,6 +319,8 @@ public class WorldMapController : MonoBehaviour if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него"; else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs)) hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}"; + else if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.occupiedBy != 0) + hintText.text = $"{node.displayName}: идёт сбор. Войско вернётся само, когда груз будет полон или точка истощится"; else if (node.canBeAttacked) OpenAttackPanel(node); } @@ -273,8 +328,10 @@ public class WorldMapController : MonoBehaviour { foreach (var node in World.Nodes) { + if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.active != true) continue; // исчезнувшая точка // Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем - float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize; + float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize + : node.type == MapNodeType.ResourcePoint ? PointSize : CitySize; float halfSize = size / 2f + 0.2f; Vector3 pos = ToWorld(node); if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node; @@ -295,9 +352,10 @@ public class WorldMapController : MonoBehaviour attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает attackInfoText.fontSizeMin = 18; attackInfoText.fontSizeMax = 32; - attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})" + - (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + - $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; + attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target) + : $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})" + + (WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") + + $"Награда за победу: {MarchManager.FormatLoot(target.rewards)}"; RefreshAttackConditions(); soldiersSlider.minValue = available > 0 ? 1 : 0; @@ -318,7 +376,8 @@ public class WorldMapController : MonoBehaviour void RefreshAttackConditions() { shownConditions = MarchManager.PlayerConditions(); - attackInfoText.text = attackInfoBase + PathText() + ConditionsWarning(shownConditions); + bool point = selectedTarget != null && selectedTarget.type == MapNodeType.ResourcePoint; + attackInfoText.text = attackInfoBase + PathText() + (point ? "" : ConditionsWarning(shownConditions)); // голод -- про бой } // Путь в одну сторону -- по скорости состава (регламент 31.10), реальное время; занятые слоты похода @@ -376,10 +435,34 @@ public class WorldMapController : MonoBehaviour : "Дома нет солдат"; } + // Ресурсная точка: запас, срок, добыча и груз на воина (регламент 31.10) + string PointInfo(MapNode target) + { + ResourcePoint p = World.GetPoint(target.id); + if (p == null) return "Этой точки больше нет"; + var r = World.PointRules; + return $"Запас: {p.stock} {ResourceGenitive[p.kind]}, точка исчезнет через {Duration(System.Math.Max(0, p.expireMs - MarchManager.NowMs))}\n" + + $"Воин добывает {r.MiningMilliPerGameHour[p.kind] / 1000f:0.#} в игровой час (5 мин), несёт {r.CargoPerSoldier[p.kind]}; осадные не несут"; + } + + // Прогноз сбора: сколько унесут и сколько займёт весь поход + string GatherPreview(int soldiers) + { + int[] units = PreviewUnits(soldiers); + ResourcePoint p = World.GetPoint(selectedTarget.id); + if (units == null || p == null) return ""; + MapNode home = World.GetNode(WorldMapLayout.PlayerCityId); + long path = World.TravelTimeMs(home, selectedTarget, units); + World.PreviewGather(p.id, units, MarchManager.NowMs + path, out int amount, out long gatherMs); + if (amount <= 0) return "унесут 0 -- точка исчезнет раньше или некому нести"; + return $"унесут {amount} {ResourceGenitive[p.kind]}, сбор {Duration(gatherMs)}, весь поход ~{Duration(path * 2 + gatherMs)}"; + } + // Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия string PreviewText(int soldiers) { if (selectedTarget == null || soldiers <= 0) return ""; + if (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers); int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт if (units == null) return ""; @@ -525,6 +608,15 @@ public class WorldMapController : MonoBehaviour EnqueuePopup(title, body); } + void HandleGatherStarted(MarchData march) + { + MapNode target = World.GetNode(march.targetNodeId); + if (march.targetEmpty) + EnqueuePopup("ТОЧКИ НЕТ", $"{target?.displayName ?? "Ресурсная точка"} исчезла или занята\nСбора не было, домой идут все {march.soldiers} воинов"); + else + hintText.text = $"Войско начало сбор: {target?.displayName}, унесёт {march.cargo}"; + } + void HandleMarchReturned(MarchData march) { string body = $"В город вернулись {march.survivors} солдат\n" + diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 48691b1..84eabc1 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -58,6 +58,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | `MarchReturnedEvent.IsGather` — поход на ресурсную точку. | | 31.10.2026 | Claude | `IArmy.MarchSlots`, `IArmy.ActiveMarches` — слоты похода. | | 30.10.2026 | Claude | События похода: `TimeMs` (когда случилось по часам игры) у `MarchStartedEvent`, `MarchBattleResolvedEvent`, `MarchReturnedEvent`; `BattleId` у `MarchBattleResolvedEvent`. | | 30.10.2026 | Claude | `BattleForecast.WinChancePermille` — шанс победы (решение №11). | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 5db962b..3b5f1ba 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -99,6 +99,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Ресурсные точки на карте мира по регламенту 31.10 (наша часть по решению №46: состояние точки и сбор; зачисление — Экономика через `IEconomy`). Обновлены World, Save, Core. | | 31.10.2026 | Claude | Скорости маршей по типам и слоты похода по регламенту 31.10 (таблица `MarchConfig`). Обновлены World, Army, Core. | | 31.10.2026 | Claude | Кривая потерь боя по регламенту 31.10 (решение №14). Обновлён Battle. | | 30.10.2026 | Claude | Мир по документу 02: события строго по времени после офлайна, время и номер боя в событиях похода, единицы у таймеров; тесты «Готово, когда» (порядок, двойная доставка, таймеры, 0/1 солдат). Обновлены World, Core. | diff --git a/Docs/Modules/Save.md b/Docs/Modules/Save.md index 6c69d82..dc02205 100644 --- a/Docs/Modules/Save.md +++ b/Docs/Modules/Save.md @@ -34,7 +34,7 @@ SaveRegistry.Register(myModule); // при запуске модуля | --- | --- | | `economy` | Экономика | | `settlement` | Поселение | -| `world` | Глобальный мир (походы и разбитые лагеря) | +| `world` | Глобальный мир (походы, разбитые лагеря, ресурсные точки) | | `circle` | Круг (простые строки, не JSON — модуль без Unity) | | `army` | Армия (простые строки: `troops=…`, `march=…`) | | `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) | @@ -106,6 +106,7 @@ SaveRegistry.Register(myModule); // при запуске модуля | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Секция `world`: ресурсные точки (`points`: запасы, сроки, кто собирает). Старые сохранения без точек читаются — точки появятся при первом расчёте мира. | | 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. | | 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). | | 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 103b67b..399256e 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -81,7 +81,28 @@ **Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы. -**Порядок событий** (документ 02: офлайн — в причинно-следственном порядке): `WorldSimulation.Advance` каждый раз берёт самое раннее событие — приход к цели (бой) или возвращение. Бой назначает время возвращения, поэтому за один вызов поход может и сразиться, и вернуться. При равном времени — бой раньше возвращения, затем по номеру похода. Раньше за один вызов сначала шли все бои, потом все возвращения — войско, вернувшееся раньше чужого боя, попадало в события после него. Повторный вызов с тем же временем ничего не создаёт заново. +**Ресурсные точки (регламент 31.10, раздел 7; решение №46)** + +Состояние точки ведёт Мир: запас, появление, истощение, конец экспедиции. Зачисление добычи и складские лимиты — Экономика: груз приходит как добыча похода (`MarchData.loot`) и зачисляется через `IEconomy` по возвращении, как трофеи боя. Числа — таблица `Assets/Resources/WorldResourceConfig.asset` (ТЕСТ). + +| Ресурс | Запас: малая / средняя / крупная | Добыча воина за игровой час | Груз на воина | +| --- | --- | --- | --- | +| Дерево (Лес) | 200 / 600 / 1500 | 2 | 12 | +| Камень (Каменоломня) | 150 / 450 / 1000 | 1,5 | 10 | +| Металл (Рудник) | 80 / 250 / 600 | 1 | 8 | +| Золото (Золотая жила) | 30 / 100 / 250 | 0,5 | 5 | + +- **Срок жизни** — 12 / 24 / 48 реальных часов с момента появления. 1 игровой час = 5 реальных минут. +- **Сбор:** дошли до точки → войско стоит и собирает (`MarchState.Gathering`). Сбор кончается, когда груз полон, запас истощён или точка исчезла — что раньше; затем физический возврат с грузом. Всё известно в момент прихода, поэтому работает и при закрытой игре. Пример: 10 пехотинцев на Лес — 120 дерева за 30 реальных минут. +- **Осадные машины груза не несут** (регламент); добывают наравне с остальными. +- **Одна точка — одно войско.** Пока к точке идёт или на ней стоит войско, отправить другое нельзя. +- **Точка исчезла до прихода** — возврат без добычи (окно «ТОЧКИ НЕТ»). Исчезла во время сбора — уносят, что успели. +- **Истощённая или исчезнувшая точка** заменяется новой того же ресурса в другом месте (место и размер — из seed и номера появления, одинаково на сервере). Убирается с карты, когда к ней уже никто не идёт. +- **На карте** — ромб цвета ресурса с подписью «запас, сколько осталось жить» или «идёт сбор». Окно отправки показывает запас, добычу и груз на воина, путь и прогноз «унесут N за T, весь поход ~X». +- **Открыто (не задано регламентом, ТЕСТ):** сколько точек на карте (сейчас по 2 каждого ресурса), как выбирается размер (сейчас равновероятно), предел добычи на точку («скорость с пределом на точку»). Нет точек еды — в регламенте их нет. +- **Позже:** нападение на занятую точку (бой в поле, победитель забирает до 50% накопленного) — когда появятся другие игроки; досрочный отзыв — технология «Связь Волхвов»; переполнение склада при возвращении (5 минут на разгрузку) — вместе с Экономикой. + +**Порядок событий** (документ 02: офлайн — в причинно-следственном порядке): `WorldSimulation.Advance` каждый раз берёт самое раннее событие — приход к цели (бой или начало сбора), конец сбора, исчезновение ресурсной точки или возвращение. Бой назначает время возвращения, поэтому за один вызов поход может и сразиться, и вернуться. При равном времени — бой раньше возвращения, затем по номеру похода. Раньше за один вызов сначала шли все бои, потом все возвращения — войско, вернувшееся раньше чужого боя, попадало в события после него. Повторный вызов с тем же временем ничего не создаёт заново. **Таймеры и единицы** (документ 02: у каждого параметра — единицы): @@ -112,6 +133,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 31.10.2026 | Claude | Ресурсные точки (регламент 31.10, раздел 7): `ResourcePoint`, `ResourcePointRules`, `WorldSimulation.Points.cs` — появление, сбор (`MarchState.Gathering`), истощение, исчезновение, замена новой точкой; события `GatherStarted`, `GatherFinished`; сохранение в секции `world`; таблица `WorldResourceConfig`; на карте — ромбы точек, окно отправки с прогнозом сбора, подпись отряда «сбор». Тесты: появление, сбор и груз, истощение и новая точка, осадные без груза, точка исчезла до прихода и во время сбора, одна точка — одно войско, сохранение посреди сбора. | | 31.10.2026 | Claude | Скорости маршей по типам (регламент 31.10: 8 / 9 / 12 / 4 клетки за реальный час, таблица `MarchConfig`), войско — по самому медленному типу, обратно — по составу выживших; `WorldSimulation.TravelTimeMs`, `AssignUnits`, `MarchSpeedRules`; слоты похода (база 2) в окне атаки и верхней строке. Тесты: скорости по типам, смешанное войско, обратный путь; старые тесты переведены на часовые марши. | | 30.10.2026 | Claude | События строго по времени после офлайна (раньше — сначала все бои, потом все возвращения); `TimeMs` у событий похода и `WorldEvent`, `BattleId` у события боя; таблица таймеров с единицами. Тесты: порядок после офлайна, повторный вызов без дублей, границы таймеров, 0 и 1 солдат. | | 30.10.2026 | Claude | Голод на момент боя — по истории голода Населения (`WasStarvingAt`), перед офлайн-боем Население досчитывает время. Окно атаки: шанс победы в процентах. |