Ресурсные точки на карте мира (регламент 31.10, наша часть по решению №46)

Мир (Bogatyri.World.Simulation, WorldSimulation.Points.cs):
- точки дерева, камня, металла, золота трёх размеров; запас, добыча воина за игровой час
  (1 игровой час = 5 реальных минут), груз на воина, срок жизни 12/24/48 реальных часов — ТЕСТ
- сбор: дошли → стоят на точке (MarchState.Gathering) до полного груза, истощения или исчезновения точки,
  затем физический возврат с грузом; всё считается в момент прихода — работает и офлайн
- осадные груза не несут; одна точка — одно войско; точка исчезла до прихода — возврат без добычи
- истощённая/исчезнувшая точка заменяется новой того же ресурса в другом месте (детерминированно)
- события GatherStarted, GatherFinished; порядок событий по времени с учётом сбора и исчезновения точек
- сохранение в секции world (points); старые сохранения без точек читаются

Зачисление груза — Экономика через IEconomy (как трофеи боя), код Валдемара не менялся.
Таблица WorldResourceConfig (Assets/Resources/WorldResourceConfig.asset).

Карта мира: точки — ромбы цвета ресурса с запасом и сроком; окно отправки с запасом, добычей,
грузом и прогнозом «унесут N за T»; окно «ТОЧКИ НЕТ»; подпись отряда «сбор», время с часами.
Город: окно «ВОЙСКО ВЕРНУЛОСЬ» пишет «Сбор … ресурсы собраны».

Открыто (не задано регламентом): число точек на карте (сейчас по 2 каждого ресурса), выбор размера,
предел добычи на точку. Позже: нападение на занятую точку, «Связь Волхвов», переполнение склада.

Тесты: появление и расстановка, сбор и груз, истощение и новая точка, осадные без груза, исчезновение
до прихода и во время сбора, одна точка — одно войско, сохранение посреди сбора. Все 54 теста проходят вне Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alexander
2026-10-31 16:28:50 +01:00
co-authored by Claude Opus 5.5
parent e8b17a7efa
commit ae9c141b49
19 changed files with 829 additions and 36 deletions
@@ -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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b95775cbe52447a6abff99bfeb488500
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+1
View File
@@ -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);
+74
View File
@@ -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>("WorldResourceConfig");
if (cached == null)
{
Debug.LogWarning("[РЕСУРСНЫЕ ТОЧКИ] Нет Assets/Resources/WorldResourceConfig.asset -- берутся числа по умолчанию.");
cached = CreateInstance<WorldResourceConfig>();
}
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]) };
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ee3759b377140c0b759a4a386327510
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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 еды" или "нет"
}
+154
View File
@@ -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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>();
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<WorldEvent>());
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<WorldEvent>());
Assert.AreEqual(stock, m.cargo, "унесут весь запас");
world.Advance(m.gatherEndMs, new List<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
Assert.AreEqual(wood.expireMs, cut.gatherEndMs, "сбор закончился вместе с точкой");
Assert.AreEqual(60, cut.cargo, "15 мин = 3 игровых часа x 20 дерева в час");
world.Advance(cut.gatherEndMs, new List<WorldEvent>());
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<WorldEvent>());
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<WorldEvent>());
// Сохранение посреди сбора: тот же мир после загрузки доводит сбор до того же итога
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
loaded.LoadPointsState(world.GetPointsState());
loaded.LoadMarches(new List<MarchData>(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<WorldEvent>();
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()
+27 -2
View File
@@ -26,6 +26,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
public event Action<MarchData> OnMarchStarted;
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
@@ -35,6 +36,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
private WorldSimulation world;
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
private List<CampState> 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<IBattleSystem>(), SpeedRules);
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>(), 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<MarchData> marches = new List<MarchData>();
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
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<CampState>(pendingCamps ?? new List<CampState>());
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<WorldSaveData>(json);
// Точки -- до походов. Старое сохранение без точек: точки появятся при первом расчёте мира
if (world != null) world.LoadPointsState(data?.points);
else pendingPoints = data?.points;
LoadMarches(data?.marches);
if (world != null) world.LoadCampStates(data?.camps);
else pendingCamps = data?.camps;
@@ -262,10 +285,12 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
pendingMarches = null;
pendingCamps = null;
pendingPoints = null;
if (world != null)
{
world.LoadMarches(new List<MarchData>());
world.LoadCampStates(null);
world.LoadPointsState(null); // ресурсные точки появятся заново
}
}
@@ -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<MapNode> nodes, out int x, out int y, int spacing = MinCitySpacing)
public static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y, int spacing = MinCitySpacing)
{
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
{
@@ -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<ResourcePoint> points = new List<ResourcePoint>();
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<ResourcePoint> points = new List<ResourcePoint>();
private int nextPointId = 1;
private int pointGeneration;
private bool pointsInitialized;
public ResourcePointRules PointRules => pointRules;
public IReadOnlyList<ResourcePoint> 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<RewardEntry>();
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));
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: caa1c535b9b8440c95d48f1928e6b78f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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<MapNode> nodes;
private readonly IBattleSystem battleSystem;
@@ -136,11 +142,14 @@ namespace Bogatyri.World
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам.
// Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию.
public WorldSimulation(List<MapNode> nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null)
public WorldSimulation(List<MapNode> 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<WorldEvent> 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;
}
+4 -2
View File
@@ -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 ? "точки уже не было или её заняли, сбора не было" : "<color=#7CD67C>ресурсы собраны</color>")
: e.TargetEmpty ? "лагерь уже был разбит, боя не было"
: e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
int lost = Mathf.Max(0, e.Sent - e.Survivors);
+11 -4
View File
@@ -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}";
}
}
+97 -5
View File
@@ -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<string, (SpriteRenderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
private readonly List<string> stalePoints = new List<string>();
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<color=#9BE09B>идёт сбор</color>"
: $"{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 "<color=#E06A5A>унесут 0 -- точка исчезнет раньше или некому нести</color>";
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" +