Экономика: EconomyConfig, модуль Населения, отладочная панель DEV

This commit is contained in:
2026-10-27 18:04:27 +03:00
parent 679405b288
commit 256a8fbc3f
28 changed files with 1096 additions and 13 deletions
@@ -1 +0,0 @@
{"TestSuite":"","Date":0,"Player":{"Development":false,"ScreenWidth":0,"ScreenHeight":0,"ScreenRefreshRate":0,"Fullscreen":false,"Vsync":0,"AntiAliasing":0,"Batchmode":false,"RenderThreadingMode":"MultiThreaded","MtRendering":false,"GraphicsJobs":false,"GpuSkinning":false,"Platform":"","ColorSpace":"","AnisotropicFiltering":"","BlendWeights":"","GraphicsApi":"","ScriptingBackend":"IL2CPP","AndroidTargetSdkVersion":"AndroidApiLevelAuto","AndroidBuildSystem":"Gradle","BuildTarget":"Android","StereoRenderingPath":"MultiPass"},"Hardware":{"OperatingSystem":"","DeviceModel":"","DeviceName":"","ProcessorType":"","ProcessorCount":0,"GraphicsDeviceName":"","SystemMemorySizeMB":0},"Editor":{"Version":"6000.5.0f1","Branch":"6000.5/staging","Changeset":"88b47c5e7076","Date":1780938000},"Dependencies":["com.unity.2d.animation@15.1.0","com.unity.2d.aseprite@5.0.3","com.unity.2d.common@14.0.1","com.unity.2d.psdimporter@14.0.3","com.unity.2d.sprite@1.0.0","com.unity.2d.spriteshape@15.0.3","com.unity.2d.tilemap.extras@8.0.3","com.unity.2d.tilemap@1.0.0","com.unity.2d.tooling@3.0.1","com.unity.burst@1.8.29","com.unity.collab-proxy@2.12.4","com.unity.collections@6.5.0","com.unity.ext.nunit@2.1.0","com.unity.ide.rider@3.0.38","com.unity.ide.visualstudio@2.0.26","com.unity.inputsystem@1.19.0","com.unity.mathematics@1.4.0","com.unity.microsoft.gdk.discovery@1.3.0","com.unity.microsoft.gdk.tools@1.7.0","com.unity.microsoft.gdk@1.7.0","com.unity.modules.accessibility@1.0.0","com.unity.modules.adaptiveperformance@1.0.0","com.unity.modules.ai@1.0.0","com.unity.modules.androidjni@1.0.0","com.unity.modules.animation@1.0.0","com.unity.modules.assetbundle@1.0.0","com.unity.modules.audio@1.0.0","com.unity.modules.cloth@1.0.0","com.unity.modules.director@1.0.0","com.unity.modules.hierarchycore@1.0.0","com.unity.modules.imageconversion@1.0.0","com.unity.modules.imgui@1.0.0","com.unity.modules.jsonserialize@1.0.0","com.unity.modules.particlesystem@1.0.0","com.unity.modules.physics@1.0.0","com.unity.modules.physics2d@1.0.0","com.unity.modules.physicscore2d@1.0.0","com.unity.modules.screencapture@1.0.0","com.unity.modules.subsystems@1.0.0","com.unity.modules.terrain@1.0.0","com.unity.modules.terrainphysics@1.0.0","com.unity.modules.tilemap@1.0.0","com.unity.modules.ui@1.0.0","com.unity.modules.uielements@1.0.0","com.unity.modules.umbra@1.0.0","com.unity.modules.unityanalytics@1.0.0","com.unity.modules.unitywebrequest@1.0.0","com.unity.modules.unitywebrequestassetbundle@1.0.0","com.unity.modules.unitywebrequestaudio@1.0.0","com.unity.modules.unitywebrequesttexture@1.0.0","com.unity.modules.unitywebrequestwww@1.0.0","com.unity.modules.vectorgraphics@1.0.0","com.unity.modules.vehicles@1.0.0","com.unity.modules.video@1.0.0","com.unity.modules.wind@1.0.0","com.unity.modules.xr@1.0.0","com.unity.multiplayer.center@1.0.1","com.unity.nuget.mono-cecil@1.11.6","com.unity.nuget.newtonsoft-json@3.2.2","com.unity.purchasing@5.4.2","com.unity.render-pipelines.core@17.5.0","com.unity.render-pipelines.universal@17.5.0","com.unity.render-pipelines.universal-config@17.5.0","com.unity.searcher@4.9.4","com.unity.services.cloud-build@2.0.8","com.unity.services.core@1.18.0","com.unity.shadergraph@17.5.0","com.unity.test-framework.performance@3.5.0","com.unity.test-framework@1.7.0","com.unity.timeline@1.8.12","com.unity.ugui@2.5.0","com.unity.visualscripting@1.9.11"],"Results":[]}
@@ -1 +0,0 @@
{"MeasurementCount":-1}
+142
View File
@@ -0,0 +1,142 @@
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// ОТЛАДОЧНАЯ ПАНЕЛЬ для проверки баланса: кнопка "DEV" слева на экране.
//
// Есть только в редакторе и в Development Build -- в релизную сборку этот файл не попадает совсем.
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
//
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение
// войск пока идут по реальным часам.
public class DebugPanel : MonoBehaviour
{
static DebugPanel instance;
GameObject panel;
TextMeshProUGUI info;
float nextRefresh;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (instance != null) return;
var go = new GameObject("DebugPanel", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
DontDestroyOnLoad(go);
var canvas = go.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 1500; // поверх HUD и окон
var scaler = go.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.matchWidthOrHeight = 0.5f;
instance = go.AddComponent<DebugPanel>();
instance.Build();
}
void Build()
{
// Кнопка-переключатель слева посередине: там нет кнопок HUD
var toggle = UIKit.Button(transform, "DEV", new Color(0.55f, 0.15f, 0.55f, 0.85f), Toggle, 110, 64, 26);
var trt = (RectTransform)toggle.transform;
trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f);
trt.pivot = new Vector2(0, 0.5f);
trt.anchoredPosition = new Vector2(12, 0);
trt.sizeDelta = new Vector2(110, 64);
var card = UIKit.Card(transform, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
card.pivot = new Vector2(0, 0.5f);
card.anchoredPosition = new Vector2(135, 0);
card.sizeDelta = new Vector2(620, 0);
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
panel = card.gameObject;
var title = UIKit.Text(card, "ОТЛАДКА БАЛАНСА", 30, UIKit.Theme.Gold, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
info = UIKit.Text(card, "", 24, UIKit.Theme.Text);
var row1 = UIKit.Row(card, 70, 10);
Btn(row1, "+1 час", UIKit.Theme.Blue, () => GameClock.Advance(3600));
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
var row2 = UIKit.Row(card, 70, 10);
Btn(row2, "+100 всех ресурсов", UIKit.Theme.Green, AddAllResources);
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
var row3 = UIKit.Row(card, 70, 10);
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); });
panel.SetActive(false);
}
static void Btn(Transform row, string label, Color color, System.Action onClick)
{
var b = UIKit.Button(row, label, color, onClick, -1, 70, 24);
UIKit.Size(b, -1, 70, 1);
}
void Toggle()
{
panel.SetActive(!panel.activeSelf);
if (panel.activeSelf) Refresh();
}
void Update()
{
if (panel == null || !panel.activeSelf || Time.unscaledTime < nextRefresh) return;
nextRefresh = Time.unscaledTime + 0.5f;
Refresh();
}
void Refresh()
{
if (info == null) return;
var sb = new System.Text.StringBuilder();
if (GameServices.TryGet(out IPopulation p))
{
sb.Append($"Население: <b>{p.Total}</b> / {p.HousingCap}\n");
sb.Append($" жители {p.Civilians} (рабочие {p.Workers}, остальные {p.Idle}), воины {p.Soldiers}\n");
sb.Append($"Еда в час: <color=#ff8a80>-{p.FoodPerHour:0.#}</color> прирост в час: +{p.GrowthPerHour:0.#}\n");
if (p.IsStarving) sb.Append("<color=#ff5252><b>ГОЛОД: еды не хватает, прирост остановлен</b></color>\n");
}
else sb.Append("Модуль Населения не зарегистрирован\n");
if (GameServices.TryGet(out IEconomy e))
sb.Append($"Еда: {e.Get(ResourceType.Food):0.#} дерево {e.Get(ResourceType.Wood):0} камень {e.Get(ResourceType.Stone):0}\n");
else sb.Append("Экономика не найдена (нет ResourceManager на сцене)\n");
long off = GameClock.OffsetSeconds;
sb.Append(off > 0 ? $"Перемотано: +{off / 3600f:0.#} ч" : "Время: реальное");
info.text = sb.ToString();
}
void AddAllResources()
{
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
foreach (var t in types) AddResource(t, 100);
}
void AddResource(ResourceType type, float amount)
{
if (GameServices.TryGet(out IEconomy e)) e.Add(type, amount);
Refresh();
}
void ZeroFood()
{
if (GameServices.TryGet(out IEconomy e)) e.Add(ResourceType.Food, -e.Get(ResourceType.Food));
Refresh();
}
void OnDestroy()
{
if (instance == this) instance = null;
}
}
#endif
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c2cba3db366a45feaaa3e1f4867ae423
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+60
View File
@@ -22,6 +22,19 @@ public static class GameModules
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyArmy(army));
// Население -- чистый C#: жители, еда, прирост (секция "population"). Числа -- из EconomyConfig.
var config = EconomyConfig.Load();
var population = new PopulationService(ToPopulationRules(config));
GameServices.Register<IPopulation>(population);
SaveRegistry.Register(population);
EventBus.Subscribe<GameLoadedEvent>(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
EventBus.Subscribe<TownHallLevelChangedEvent>(_ => population.Changed()); // предел жилья вырос
EventBus.Subscribe<PopulationChangedEvent>(SyncPopulationResource);
EventBus.Subscribe<NewGameStartedEvent>(_ => GiveStartingKit(population, army, config));
EventBus.Subscribe<GameResetEvent>(_ => GiveStartingKit(population, army, config));
// Круг -- чистый C#, сам сохраняет, в каком Круге игрок (секция "circle")
var circle = new CircleService();
GameServices.Register<ICircle>(circle);
@@ -97,6 +110,53 @@ public static class GameModules
GameProgress.Instance.MarkChanged();
}
// ---------------- Население и экономика ----------------
static PopulationRules ToPopulationRules(EconomyConfig config)
{
var p = config.population;
return new PopulationRules
{
StartTotal = p.startTotal,
StartWorkers = p.startWorkers,
StartSoldiers = p.startSoldiers,
FoodPerCivilianPerHour = p.foodPerCivilianPerHour,
FoodPerSoldierPerHour = p.foodPerSoldierPerHour,
GrowthPerHour = p.growthPerHour,
HousingBase = p.housingBase,
HousingPerTownHallLevel = p.housingPerTownHallLevel,
MaxOfflineHours = p.maxOfflineHours
};
}
// Новая игра: стартовое войско (пехота) и стартовые ресурсы из EconomyConfig. Один раз на новую игру.
static void GiveStartingKit(PopulationService population, ArmyService army, EconomyConfig config)
{
if (!population.TryTakeStartingKit()) return;
if (population.Rules.StartSoldiers > 0) army.AddTroops(0, population.Rules.StartSoldiers);
if (GameServices.TryGet(out IEconomy economy))
{
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
foreach (var t in types)
{
float target = config.GetStartResource(t);
float current = economy.Get(t);
if (target > current) economy.Add(t, target - current); // не меньше стартового
}
}
Debug.Log($"[НАСЕЛЕНИЕ] Новая игра: {population.Total} человек, воинов {population.Soldiers}.");
}
// Верхняя панель показывает людей как ресурс "Люди" -- держим его равным населению
static void SyncPopulationResource(PopulationChangedEvent e)
{
if (!GameServices.TryGet(out IEconomy economy)) return;
float delta = e.Total - economy.Get(ResourceType.Population);
if (Mathf.Abs(delta) >= 0.5f) economy.Add(ResourceType.Population, delta);
}
static void OnMarchStarted(MarchStartedEvent e)
{
var data = GameProgress.Instance.Data;
+38
View File
@@ -0,0 +1,38 @@
using UnityEngine;
// Слой приложения: "сердцебиение" модуля Населения. Модуль -- чистый C# и не знает про Update,
// поэтому раз в несколько секунд его досчитывает этот объект. Живёт между сценами (город <-> карта мира).
// Создаётся сам при запуске игры, вешать никуда не нужно.
public class PopulationTicker : MonoBehaviour
{
const float Interval = 2f; // секунд реального времени между расчётами
static PopulationTicker instance;
float next;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (instance != null) return;
var go = new GameObject("PopulationTicker");
DontDestroyOnLoad(go);
instance = go.AddComponent<PopulationTicker>();
}
void Start()
{
next = Time.unscaledTime + Interval; // первый расчёт -- после загрузки сохранения (она идёт в Start сцены)
}
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + Interval;
if (GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow);
}
void OnDestroy()
{
if (instance == this) instance = null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d7b4f971ff440eaa5744fbe2a1e834d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -11,6 +11,7 @@ public static class CoreUnityBootstrap
GameServices.Clear();
EventBus.Clear();
SaveRegistry.Clear();
GameClock.ResetOffset(); // отладочная перемотка не переживает перезапуск
EventBus.OnHandlerError = Debug.LogException;
}
}
@@ -32,6 +32,24 @@ public interface ILeveledBuilding
int Level { get; set; }
}
// ---------------- Население: жители, еда, прирост ----------------
public interface IPopulation
{
int Total { get; } // все люди города: жители + воины (в городе и в походах)
int Civilians { get; } // жители (не воины)
int Workers { get; } // жители на работе (этап 3: распределение по зданиям)
int Idle { get; } // остальные жители: Civilians - Workers
int Soldiers { get; } // воины -- их хранит модуль Армии
int HousingCap { get; } // предел населения: выше него прирост не идёт
float FoodPerHour { get; } // сколько город съедает в час
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
bool IsStarving { get; } // еды не хватило на последний расчёт
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
void Tick(long unixNow);
}
// ---------------- Армия: войска ----------------
public interface IArmy
{
@@ -24,6 +24,30 @@ public struct TownHallLevelChangedEvent
// здания, которые появляются кодом (например, Казармы)
public struct SettlementRestoringEvent { }
// ---------------- Время ----------------
// Время игры промотано вперёд (отладочная панель). Модули досчитывают прошедшее время.
public struct GameTimeAdvancedEvent
{
public long Seconds;
public long UnixNow;
}
// ---------------- Население ----------------
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
public struct PopulationChangedEvent
{
public int Total;
public int Civilians;
public int Soldiers;
public int HousingCap;
}
// Голод начался (IsStarving = true) или закончился
public struct StarvationChangedEvent
{
public bool IsStarving;
}
// ---------------- Армия ----------------
// Число воинов изменилось (обучение, потери, поход, возвращение, загрузка)
public struct TroopsChangedEvent
@@ -85,6 +109,8 @@ public struct CircleLeftEvent
// ---------------- Сохранения ----------------
public struct GameLoadedEvent { }
// Файла сохранения нет -- это первая игра на устройстве (стартовые ресурсы, стартовое войско)
public struct NewGameStartedEvent { }
public struct GameSavedEvent { }
// Сохранения сброшены, все модули уже в состоянии новой игры
public struct GameResetEvent { }
+42
View File
@@ -0,0 +1,42 @@
using System;
// ЧАСЫ ИГРЫ: единственный источник "сейчас" для игровых правил (население, а дальше -- производство).
//
// Время игры = реальное время UTC + сдвиг. Сдвиг нужен только отладочной панели ("+1 час"):
// перемотка идёт через те же правила, что и офлайн-прогресс, отдельной логики у неё нет.
// Сдвиг НЕ сохраняется -- после перезапуска игры время снова реальное.
//
// Модули берут время только отсюда: GameClock.UnixNow, а не DateTime.UtcNow.
public static class GameClock
{
static long offsetSeconds;
// Для тестов: подменить "реальное" время. null -- настоящие часы устройства.
public static Func<long> RealUnixSource;
public static long OffsetSeconds => offsetSeconds;
// Текущее время игры, секунды Unix
public static long UnixNow => RealUnix() + offsetSeconds;
public static DateTime UtcNow => DateTimeOffset.FromUnixTimeSeconds(UnixNow).UtcDateTime;
// Промотать время вперёд (отладка). Модули узнают об этом из GameTimeAdvancedEvent и досчитываются.
public static void Advance(long seconds)
{
if (seconds <= 0) return;
offsetSeconds += seconds;
EventBus.Publish(new GameTimeAdvancedEvent { Seconds = seconds, UnixNow = UnixNow });
}
// Вернуть реальное время (сдвиг = 0). Модули воспримут это как "часы ушли назад" и просто продолжат с этого момента.
public static void ResetOffset()
{
offsetSeconds = 0;
}
static long RealUnix()
{
return RealUnixSource != null ? RealUnixSource() : DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 03f995a4fe964f48bfe8d7b2f7215ecc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,6 +1,7 @@
fileFormatVersion: 2
guid: 2fec63729866dd44483350cddf3c2b84
TextScriptImporter:
guid: 1df99e6793644da19a8df1e9da262cd6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Population",
"rootNamespace": "",
"references": ["Bogatyri.Core"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -1,6 +1,6 @@
fileFormatVersion: 2
guid: 3a842e2b03903434ba52aa026b801445
TextScriptImporter:
guid: 253ae5cb48ed4a4a8153210ae03920f0
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
@@ -0,0 +1,25 @@
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
public sealed class PopulationRules
{
// Стартовый город T1: 300 человек = 120 рабочих + 30 воинов + 150 остальных
public int StartTotal = 300;
public int StartWorkers = 120;
public int StartSoldiers = 30;
// Еда в час
public double FoodPerCivilianPerHour = 0.1;
public double FoodPerSoldierPerHour = 0.2;
// Прирост +2–3 человека в час, если есть еда
public double GrowthPerHour = 2.5;
// Предел населения (жильё). Растёт с уровнем Ратуши. Числа НЕ утверждены командой.
public int HousingBase = 400;
public int HousingPerTownHallLevel = 100;
// Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено.
public int MaxOfflineHours = 48;
public int StartCivilians => StartTotal - StartSoldiers;
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 30bbbd3ca6154dda923f86163c3b90a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,270 @@
using System;
using System.Globalization;
using System.Text;
// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, сколько они едят и как прирастают. Документ: Docs/Modules/Population.md
// Без Unity: правила те же, что будут на сервере.
//
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
//
// Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре,
// при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же.
public sealed class PopulationService : IPopulation, ISaveParticipant
{
const long SecondsPerHour = 3600;
const long StepSeconds = 600; // офлайн-время считаем кусками по 10 минут -- так же, как в игре
const double Epsilon = 1e-9;
readonly PopulationRules rules;
int civilians;
int workers;
double growthProgress; // накопленная доля следующего человека (0..1)
double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды)
long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу
bool starving;
bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение)
public PopulationService(PopulationRules rules)
{
this.rules = rules ?? new PopulationRules();
SetStartState();
}
public PopulationRules Rules => rules;
public long LastTickUnix => lastTickUnix;
public bool RestoredFromSave { get; private set; }
// ---------------- Контракт IPopulation ----------------
public int Civilians => civilians;
public int Workers => Math.Min(workers, civilians);
public int Idle => civilians - Workers;
public int Soldiers
{
get
{
if (!GameServices.TryGet(out IArmy army)) return 0;
return army.SoldiersAtHome + army.TroopsInMarches;
}
}
public int Total => civilians + Soldiers;
public int HousingCap
{
get
{
int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1;
return rules.HousingBase + rules.HousingPerTownHallLevel * (level - 1);
}
}
public float FoodPerHour => (float)FoodPerHourExact(Soldiers);
public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour;
public bool IsStarving => starving;
double FoodPerHourExact(int soldiers)
{
return civilians * rules.FoodPerCivilianPerHour + soldiers * rules.FoodPerSoldierPerHour;
}
public void Tick(long unixNow)
{
if (lastTickUnix == 0)
{
lastTickUnix = unixNow; // первый расчёт: отсюда и считаем
Changed();
return;
}
long elapsed = unixNow - lastTickUnix;
if (elapsed < 0)
{
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас"
lastTickUnix = unixNow;
return;
}
if (elapsed == 0) return;
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
if (elapsed > max) elapsed = max;
Simulate(elapsed);
lastTickUnix = unixNow;
}
// ---------------- Расчёт ----------------
void Simulate(long seconds)
{
if (!GameServices.TryGet(out IEconomy economy)) return; // без склада еды считать нечего
int totalBefore = Total;
bool starvingBefore = starving;
int soldiers = Soldiers;
long remaining = seconds;
while (remaining > 0)
{
long step = Math.Min(remaining, StepSeconds);
remaining -= step;
double hours = step / (double)SecondsPerHour;
// 1. Едим. Со склада списываются только целые единицы, доли копятся здесь (foodOwed):
// склад хранит float, и тысячи мелких списаний по 0.02 давали бы ошибку округления.
foodOwed += FoodPerHourExact(soldiers) * hours;
int portion = (int)Math.Floor(foodOwed + Epsilon);
double have = economy.Get(ResourceType.Food);
bool fed;
if (portion <= 0)
{
fed = have >= foodOwed - Epsilon; // пока едим из уже взятого -- голод, только если склад пуст
}
else if (have >= portion)
{
economy.Add(ResourceType.Food, -portion);
foodOwed -= portion;
fed = true;
}
else
{
int rest = (int)Math.Floor(have);
if (rest > 0) economy.Add(ResourceType.Food, -rest); // доедаем остатки
foodOwed = 0; // долг за голодное время не копим
fed = false;
}
starving = !fed;
// 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда.
if (fed)
{
int room = HousingCap - (civilians + soldiers);
if (room > 0)
{
growthProgress += rules.GrowthPerHour * hours;
int born = (int)Math.Floor(growthProgress + Epsilon);
if (born > 0)
{
growthProgress = Math.Max(0, growthProgress - born);
civilians += Math.Min(born, room);
}
}
else
{
growthProgress = 0; // упёрлись в предел -- не копим "запас" людей
}
}
}
if (starving != starvingBefore) EventBus.Publish(new StarvationChangedEvent { IsStarving = starving });
if (Total != totalBefore) Changed();
}
// ---------------- Связь с остальной игрой (вызывает App/GameModules) ----------------
// Сохранение загружено: досчитать офлайн-время. Старое сохранение без секции населения -- стартовый набор не выдаём.
public void OnGameLoaded(long unixNow)
{
startingKitGiven = true;
Tick(unixNow);
Changed();
}
// Новая игра (нет файла) или сброс: стартовый набор (войско, ресурсы) выдаётся один раз.
// true -- выдать сейчас; false -- уже выдан или это старое сохранение.
public bool TryTakeStartingKit()
{
if (startingKitGiven) return false;
startingKitGiven = true;
return true;
}
// Изменилась Армия или Ратуша -- пересчитать общее число людей для интерфейса
public void Changed()
{
EventBus.Publish(new PopulationChangedEvent
{
Total = Total,
Civilians = civilians,
Soldiers = Soldiers,
HousingCap = HousingCap
});
}
void SetStartState()
{
civilians = Math.Max(0, rules.StartCivilians);
workers = Math.Min(rules.StartWorkers, civilians);
growthProgress = 0;
foodOwed = 0;
lastTickUnix = 0;
starving = false;
startingKitGiven = false;
}
// ---------------- Сохранение (секция "population") ----------------
// Модуль без Unity, поэтому формат -- простые строки "ключ=значение", как у Армии.
public string SaveKey => "population";
public string Capture()
{
var sb = new StringBuilder();
sb.Append("civilians=").Append(civilians.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("workers=").Append(workers.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("growth=").Append(growthProgress.ToString("R", CultureInfo.InvariantCulture)).Append('\n');
sb.Append("food=").Append(foodOwed.ToString("R", CultureInfo.InvariantCulture)).Append('\n');
sb.Append("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("starving=").Append(starving ? "1" : "0").Append('\n');
sb.Append("kit=").Append(startingKitGiven ? "1" : "0").Append('\n');
return sb.ToString();
}
public void Restore(string data)
{
SetStartState();
RestoredFromSave = true;
if (string.IsNullOrEmpty(data)) return;
foreach (var raw in data.Split('\n'))
{
string line = raw.Trim();
int eq = line.IndexOf('=');
if (eq <= 0) continue;
string key = line.Substring(0, eq);
string value = line.Substring(eq + 1);
switch (key)
{
case "civilians": civilians = Math.Max(0, ParseInt(value, civilians)); break;
case "workers": workers = Math.Max(0, ParseInt(value, workers)); break;
case "growth":
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double g)) growthProgress = Math.Max(0, Math.Min(0.999, g));
break;
case "food":
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double f)) foodOwed = Math.Max(0, Math.Min(1, f));
break;
case "lastTick":
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) lastTickUnix = Math.Max(0, t);
break;
case "starving": starving = value == "1"; break;
case "kit": startingKitGiven = value == "1"; break;
}
}
}
// Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей.
public void ResetState()
{
SetStartState();
Changed();
}
static int ParseInt(string s, int fallback)
{
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : fallback;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 46182f0762894c3aab9763821d2c9c19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -16,6 +16,7 @@ public class GameBootstrap : MonoBehaviour
else
{
Debug.Log("[СТАРТ] Сохранения нет, начинаем новую игру.");
EventBus.Publish(new NewGameStartedEvent()); // стартовые ресурсы и войско -- в App/GameModules
}
}
}
@@ -6,6 +6,7 @@
"UnityEditor.TestRunner",
"Bogatyri.Core",
"Bogatyri.Army",
"Bogatyri.Population",
"Bogatyri.Battle",
"Bogatyri.World.Simulation",
"Bogatyri.Chat",
@@ -0,0 +1,268 @@
using System.Collections.Generic;
using NUnit.Framework;
// Тесты модуля Населения (этап 2). Запуск: Window -> General -> Test Runner -> EditMode -> Run All.
// Цифры -- из дизайн-документа: стартовый город 300 человек (из них 30 воинов) ест 33 еды в час.
public class PopulationTests
{
const long T0 = 1_800_000_000; // произвольный момент времени, секунды Unix
const long Hour = 3600;
FakeEconomy economy;
FakeSettlement settlement;
ArmyService army;
[SetUp]
public void SetUp()
{
GameServices.Clear();
EventBus.Clear();
SaveRegistry.Clear();
GameClock.ResetOffset();
GameClock.RealUnixSource = null;
economy = new FakeEconomy();
settlement = new FakeSettlement { TownHallLevel = 1 };
army = new ArmyService();
GameServices.Register<IEconomy>(economy);
GameServices.Register<ISettlement>(settlement);
GameServices.Register<IArmy>(army);
}
[TearDown]
public void TearDown()
{
GameClock.RealUnixSource = null;
GameClock.ResetOffset();
}
// Стартовый город: 270 жителей + 30 воинов в Армии
PopulationService StartCity(PopulationRules rules = null)
{
var p = new PopulationService(rules ?? new PopulationRules());
army.AddTroops(0, p.Rules.StartSoldiers);
p.Tick(T0); // первый расчёт только запоминает время
return p;
}
[Test]
public void StartCity_Has300People_AndEats33FoodPerHour()
{
economy.Set(ResourceType.Food, 1000);
var p = StartCity();
Assert.AreEqual(300, p.Total);
Assert.AreEqual(270, p.Civilians);
Assert.AreEqual(120, p.Workers);
Assert.AreEqual(150, p.Idle);
Assert.AreEqual(30, p.Soldiers);
Assert.AreEqual(33f, p.FoodPerHour, 0.001f);
p.Tick(T0 + Hour);
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "за час город съел 33 еды");
}
[Test]
public void WithFood_GrowsTwoAndHalfPerHour()
{
economy.Set(ResourceType.Food, 10000);
var p = StartCity();
p.Tick(T0 + 10 * Hour);
Assert.AreEqual(300 + 25, p.Total, "за 10 часов +25 человек");
Assert.IsFalse(p.IsStarving);
}
[Test]
public void NoFood_GrowthStops_AndStarvationStarts()
{
economy.Set(ResourceType.Food, 0);
var p = StartCity();
bool? starvingEvent = null;
EventBus.Subscribe<StarvationChangedEvent>(e => starvingEvent = e.IsStarving);
p.Tick(T0 + 10 * Hour);
Assert.AreEqual(300, p.Total, "без еды прироста нет");
Assert.IsTrue(p.IsStarving);
Assert.AreEqual(true, starvingEvent);
Assert.AreEqual(0f, p.GrowthPerHour);
Assert.AreEqual(0, economy.Get(ResourceType.Food), 0.0001);
}
[Test]
public void FoodRunsOutMidway_EatsTheRest_ThenStarves()
{
economy.Set(ResourceType.Food, 50); // хватит на полтора часа
var p = StartCity();
p.Tick(T0 + 3 * Hour);
Assert.AreEqual(0, economy.Get(ResourceType.Food), 0.0001, "остатки доедены");
Assert.IsTrue(p.IsStarving);
}
[Test]
public void FoodReturns_StarvationEnds_GrowthResumes()
{
economy.Set(ResourceType.Food, 0);
var p = StartCity();
p.Tick(T0 + Hour);
Assert.IsTrue(p.IsStarving);
economy.Set(ResourceType.Food, 10000);
p.Tick(T0 + 5 * Hour);
Assert.IsFalse(p.IsStarving);
Assert.Greater(p.Total, 300);
}
[Test]
public void HousingCap_StopsGrowth_HigherTownHall_ResumesIt()
{
economy.Set(ResourceType.Food, 100000);
var p = StartCity(new PopulationRules { HousingBase = 300, HousingPerTownHallLevel = 100 });
p.Tick(T0 + 10 * Hour);
Assert.AreEqual(300, p.Total, "300 жителей при пределе 300 -- прирост стоит");
Assert.AreEqual(0f, p.GrowthPerHour);
settlement.TownHallLevel = 2; // предел 400
p.Tick(T0 + 20 * Hour);
Assert.AreEqual(325, p.Total, "предел вырос -- прирост пошёл");
}
[Test]
public void Growth_NeverExceedsHousingCap()
{
economy.Set(ResourceType.Food, 100000);
var p = StartCity(new PopulationRules { HousingBase = 305 });
p.Tick(T0 + 40 * Hour);
Assert.AreEqual(305, p.Total);
}
[Test]
public void Offline_IsCappedAtMaxHours()
{
economy.Set(ResourceType.Food, 100000);
var p = StartCity(new PopulationRules { MaxOfflineHours = 48 });
p.Tick(T0 + 100 * Hour);
double eaten = 100000 - economy.Get(ResourceType.Food);
Assert.Greater(eaten, 33 * 48 - 1, "посчитано 48 часов");
Assert.Less(eaten, 33 * 60, "а не 100 (за 48 часов прибавилось 120 человек -- ели чуть больше 33 в час)");
Assert.AreEqual(T0 + 100 * Hour, p.LastTickUnix, "дальше считаем от 'сейчас'");
}
[Test]
public void ManySmallTicks_EqualOneBigTick()
{
economy.Set(ResourceType.Food, 10000);
var a = StartCity();
for (long t = T0 + 2; t <= T0 + 10 * Hour; t += 2) a.Tick(t); // как в игре: раз в 2 секунды
float foodA = economy.Get(ResourceType.Food);
int totalA = a.Total;
SetUp();
economy.Set(ResourceType.Food, 10000);
var b = StartCity();
b.Tick(T0 + 10 * Hour); // как офлайн-прогресс
Assert.AreEqual(totalA, b.Total);
Assert.AreEqual(foodA, economy.Get(ResourceType.Food), 3.0, "игра и офлайн считают одинаково (разница -- доли часа, когда родился человек)");
}
[Test]
public void ClockGoesBack_NothingHappens_ThenContinues()
{
economy.Set(ResourceType.Food, 1000);
var p = StartCity();
p.Tick(T0 - 5000);
Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "время назад -- ничего не съедено");
Assert.AreEqual(300, p.Total);
p.Tick(T0 - 5000 + Hour);
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01);
}
[Test]
public void DebugAdvance_GoesThroughTheSameRules()
{
long real = T0;
GameClock.RealUnixSource = () => real;
economy.Set(ResourceType.Food, 1000);
var p = StartCity();
EventBus.Subscribe<GameTimeAdvancedEvent>(e => p.Tick(e.UnixNow)); // как в App/GameModules
GameClock.Advance(Hour);
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "+1 час = час обычных правил");
Assert.AreEqual(Hour, GameClock.OffsetSeconds);
GameClock.ResetOffset(); // сдвиг сброшен -- время "назад", ничего не ломается
p.Tick(GameClock.UnixNow);
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01);
}
[Test]
public void SaveAndRestore_KeepsEverything()
{
economy.Set(ResourceType.Food, 10000);
var p = StartCity();
p.Tick(T0 + 3 * Hour); // +7 человек и доля восьмого
string saved = p.Capture();
var q = new PopulationService(new PopulationRules());
q.Restore(saved);
Assert.IsTrue(q.RestoredFromSave);
Assert.AreEqual(p.Civilians, q.Civilians);
Assert.AreEqual(p.Workers, q.Workers);
Assert.AreEqual(p.LastTickUnix, q.LastTickUnix);
}
[Test]
public void StartingKit_IsGivenOnce_AndNotToOldSaves()
{
var fresh = new PopulationService(new PopulationRules());
Assert.IsTrue(fresh.TryTakeStartingKit(), "новая игра -- выдать");
Assert.IsFalse(fresh.TryTakeStartingKit(), "второй раз -- нет");
var old = new PopulationService(new PopulationRules());
old.OnGameLoaded(T0); // старое сохранение без секции населения
Assert.IsFalse(old.TryTakeStartingKit(), "старому игроку стартовый набор не выдаётся");
old.ResetState(); // сброс прогресса -- новая игра
Assert.IsTrue(old.TryTakeStartingKit());
}
[Test]
public void Reset_ReturnsToStartCity()
{
economy.Set(ResourceType.Food, 10000);
var p = StartCity();
p.Tick(T0 + 10 * Hour);
p.ResetState();
Assert.AreEqual(270, p.Civilians);
Assert.AreEqual(0, p.LastTickUnix);
Assert.IsFalse(p.IsStarving);
}
// ---------------- Заглушки модулей ----------------
class FakeEconomy : IEconomy
{
readonly Dictionary<ResourceType, float> res = new Dictionary<ResourceType, float>();
public void Set(ResourceType t, float v) { res[t] = v; }
public float Get(ResourceType type) => res.TryGetValue(type, out float v) ? v : 0f;
public bool Has(ResourceType type, float amount) => Get(type) >= amount;
public void Add(ResourceType type, float amount) { float v = Get(type) + amount; res[type] = v < 0 ? 0 : v; }
public bool TrySpend(ResourceType type, float amount) { if (!Has(type, amount)) return false; Add(type, -amount); return true; }
public bool CanAfford(IReadOnlyList<ResourceAmount> cost) { foreach (var c in cost) if (!Has(c.Type, c.Amount)) return false; return true; }
public bool TrySpend(IReadOnlyList<ResourceAmount> cost) { if (!CanAfford(cost)) return false; foreach (var c in cost) Add(c.Type, -c.Amount); return true; }
}
class FakeSettlement : ISettlement
{
public int TownHallLevel { get; set; }
public int BuildingCount => 0;
public bool IsCityLoaded => true;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c6570cf436af4ee1bc60a25c8714b844
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: