Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0876ee9ac6 |
@@ -0,0 +1,45 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply from: '../shared/keepUnitySymbols.gradle'
|
||||
apply from: '../shared/common.gradle'
|
||||
**APPLY_PLUGINS**
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.22')
|
||||
**DEPS**}
|
||||
|
||||
android {
|
||||
namespace "com.unity3d.player"
|
||||
ndkPath "**NDKPATH**"
|
||||
ndkVersion "**NDKVERSION**"
|
||||
compileSdk **APIVERSION**
|
||||
buildToolsVersion "**BUILDTOOLS**"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk **MINSDK**
|
||||
targetSdk **TARGETSDK**
|
||||
ndk {
|
||||
abiFilters **ABIFILTERS**
|
||||
debugSymbolLevel **DEBUGSYMBOLLEVEL**
|
||||
}
|
||||
consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD**
|
||||
**DEFAULT_CONFIG_SETUP**
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
androidResources {
|
||||
noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ')
|
||||
ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~"
|
||||
}**PACKAGING**
|
||||
}
|
||||
**IL_CPP_BUILD_SETUP**
|
||||
**SOURCE_BUILD_SETUP**
|
||||
**EXTERNAL_SOURCES**
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f9b52d13f334e20be4bad9f8a86c74b
|
||||
folderAsset: yes
|
||||
guid: afabcf797b4c170459251e1f1c3996b6
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
@@ -1,66 +0,0 @@
|
||||
%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: 4ddb59400c6d4b08b3afaaadc461ec1e, type: 3}
|
||||
m_Name: EconomyConfig
|
||||
m_EditorClassIdentifier:
|
||||
population:
|
||||
startTotal: 300
|
||||
startWorkers: 120
|
||||
startSoldiers: 30
|
||||
foodPerCivilianPerHour: 0.1
|
||||
foodPerSoldierPerHour: 0.2
|
||||
growthPerHour: 2.5
|
||||
housingBase: 300
|
||||
housingPerTownHallLevel: 50
|
||||
housingPerHouseLevel: 50
|
||||
maxOfflineHours: 48
|
||||
startResources:
|
||||
wood: 500
|
||||
food: 500
|
||||
stone: 200
|
||||
iron: 100
|
||||
gold: 100
|
||||
gems: 0
|
||||
storage:
|
||||
baseGoods: 1000
|
||||
baseFood: 1500
|
||||
perWarehouseLevel: 1000
|
||||
perGranaryLevel: 1500
|
||||
crystalCap: 50
|
||||
warehouseWood: 200
|
||||
warehouseStone: 100
|
||||
granaryWood: 150
|
||||
granaryStone: 50
|
||||
production:
|
||||
woodcutterPerHour: 2
|
||||
farmerPerHour: 2
|
||||
stonecutterPerHour: 1.5
|
||||
minerIronPerHour: 1
|
||||
minerGoldPerHour: 0.4
|
||||
workerSlotsT1: 30
|
||||
workerSlotsPerLevel: 10
|
||||
efficiencyPerLevel: 0.1
|
||||
upgrade:
|
||||
baseWood: 150
|
||||
baseStone: 100
|
||||
multiplierPerLevel: 1.6
|
||||
maxLevel: 10
|
||||
deposits:
|
||||
poor: 0.6
|
||||
normal: 1
|
||||
rich: 1.5
|
||||
soldier:
|
||||
people: 1
|
||||
food: 15
|
||||
iron: 5
|
||||
gold: 5
|
||||
trainSeconds: 60
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db192d63d5ff4504a4624613292cf3ab
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1 @@
|
||||
{"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":[]}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 253ae5cb48ed4a4a8153210ae03920f0
|
||||
AssemblyDefinitionImporter:
|
||||
guid: 3a842e2b03903434ba52aa026b801445
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
@@ -0,0 +1 @@
|
||||
{"MeasurementCount":-1}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a5b231268f445349a58134b71372e9b
|
||||
AssemblyDefinitionImporter:
|
||||
guid: 2fec63729866dd44483350cddf3c2b84
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
@@ -1,158 +0,0 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
// Окно здания и рабочих (этап 3). Открывается тапом по зданию или кнопкой "Рабочие" в панели DEV.
|
||||
// - сверху: что это за здание, его уровень и кнопка "Улучшить" (больше мест и эффективность / больше лимит / больше жилья);
|
||||
// - ниже: все профессии с "−" и "+", сколько рабочих стоит, сколько мест и сколько добывается в час;
|
||||
// - внизу: "Свободно: N из M".
|
||||
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль добычи.
|
||||
public static class BuildingWindow
|
||||
{
|
||||
static GameObject root;
|
||||
static GameObject focus; // здание, по которому тапнули (null -- просто список рабочих)
|
||||
|
||||
public static void OnWorkplaceTapped(ResourceCollector building) { Open(building != null ? building.gameObject : null); }
|
||||
public static void OnStorageTapped(StorageBuilding building) { Open(building != null ? building.gameObject : null); }
|
||||
|
||||
public static void Open(GameObject building)
|
||||
{
|
||||
// В режиме стройки тап -- это выбор участка, а не здания
|
||||
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
||||
focus = building;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public static void Close()
|
||||
{
|
||||
if (root != null) Object.Destroy(root);
|
||||
root = null;
|
||||
focus = null;
|
||||
}
|
||||
|
||||
static void Rebuild()
|
||||
{
|
||||
if (root != null) Object.Destroy(root);
|
||||
var card = UIKit.Modal(TitleFor(focus), out root);
|
||||
|
||||
if (focus != null) BuildingSection(card, focus);
|
||||
WorkersSection(card);
|
||||
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
|
||||
}
|
||||
|
||||
static string TitleFor(GameObject building)
|
||||
{
|
||||
if (building == null) return "РАБОЧИЕ";
|
||||
var id = building.GetComponent<PlacedBuildingId>();
|
||||
var leveled = building.GetComponent<ILeveledBuilding>();
|
||||
string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name;
|
||||
return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper();
|
||||
}
|
||||
|
||||
// ---------------- Здание: что даёт и улучшение ----------------
|
||||
|
||||
static void BuildingSection(RectTransform card, GameObject building)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
var rc = building.GetComponent<ResourceCollector>();
|
||||
var storage = building.GetComponent<StorageBuilding>();
|
||||
var leveled = building.GetComponent<ILeveledBuilding>();
|
||||
string info;
|
||||
|
||||
if (rc != null && WorkJobs.TryFromResource(rc.resourceType, out WorkJob job) && GameModules.Production != null)
|
||||
{
|
||||
var p = GameModules.Production;
|
||||
info = $"Здание даёт {p.BuildingSlots(rc.level)} мест, норма ×{p.BuildingEfficiency(rc.level):0.##}.\n" +
|
||||
$"Всего мест «{WorkJobs.Name(job)}» в городе: {p.Slots(job)}.";
|
||||
}
|
||||
else if (rc != null && rc.IsHouse)
|
||||
{
|
||||
int perLevel = config.population.housingPerHouseLevel;
|
||||
string cap = GameServices.TryGet(out IPopulation pop) ? $"\nНаселение: {pop.Total} / {pop.HousingCap}" : "";
|
||||
info = $"Дом: +{perLevel * rc.level} к пределу жилья (+{perLevel} за уровень).{cap}";
|
||||
}
|
||||
else if (rc != null && rc.resourceType == ResourceType.Gems)
|
||||
{
|
||||
info = "Синий кристалл добывается в кристальных разломах на карте мира.";
|
||||
}
|
||||
else if (storage != null)
|
||||
{
|
||||
var s = config.storage;
|
||||
info = storage.kind == StorageBuilding.Kind.Warehouse
|
||||
? $"Склад: +{s.perWarehouseLevel * storage.level:0} к лимиту дерева, камня, металла и золота."
|
||||
: $"Амбар: +{s.perGranaryLevel * storage.level:0} к лимиту еды.";
|
||||
}
|
||||
else info = "";
|
||||
|
||||
if (info.Length > 0) UIKit.Text(card, info, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (leveled == null || (rc != null && rc.resourceType == ResourceType.Gems)) return;
|
||||
if (leveled.Level >= config.upgrade.maxLevel)
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
var cost = config.UpgradeCost(leveled.Level);
|
||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
||||
var b = UIKit.Button(card, $"Улучшить до {leveled.Level + 1} ур. ({CostText(cost)})", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
leveled.Level = leveled.Level + 1;
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {leveled.Level} уровня");
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
}
|
||||
|
||||
static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
var parts = new string[cost.Length];
|
||||
for (int i = 0; i < cost.Length; i++) parts[i] = $"{cost[i].Amount:0} {GameData.ResName(cost[i].Type).ToLower()}";
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
// ---------------- Рабочие по профессиям ----------------
|
||||
|
||||
static void WorkersSection(RectTransform card)
|
||||
{
|
||||
if (!GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod))
|
||||
{
|
||||
UIKit.Text(card, "Модули Населения и добычи не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
{
|
||||
var job = (WorkJob)j;
|
||||
int slots = prod.Slots(job);
|
||||
int workers = pop.GetWorkers(job);
|
||||
var row = UIKit.Row(card, 72, 8);
|
||||
|
||||
string rate = slots > 0 ? $" <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>" : "";
|
||||
var label = UIKit.Text(row, slots > 0 ? $"{WorkJobs.Name(job)}: {workers} / {slots}{rate}" : $"{WorkJobs.Name(job)}: нет зданий",
|
||||
24, slots > 0 ? UIKit.Theme.Text : UIKit.Theme.TextDim);
|
||||
label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24;
|
||||
UIKit.Size(label, -1, 72, 1);
|
||||
|
||||
StepButton(row, "−5", job, -5, slots > 0 && workers > 0);
|
||||
StepButton(row, "−1", job, -1, slots > 0 && workers > 0);
|
||||
StepButton(row, "+1", job, +1, slots > 0 && workers < slots && pop.FreeWorkers > 0);
|
||||
StepButton(row, "+5", job, +5, slots > 0 && workers < slots && pop.FreeWorkers > 0);
|
||||
}
|
||||
|
||||
UIKit.Text(card, $"Свободно: <b>{pop.FreeWorkers}</b> из {pop.Workforce} рабочих • остальные жители: {Mathf.Max(0, pop.Civilians - pop.Workforce)}",
|
||||
24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
static void StepButton(Transform row, string label, WorkJob job, int delta, bool enabled)
|
||||
{
|
||||
var b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (GameServices.TryGet(out IPopulation pop)) pop.SetWorkers(job, pop.GetWorkers(job) + delta);
|
||||
Rebuild();
|
||||
}, 86, 64, 24);
|
||||
b.interactable = enabled;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50195a725abc4fe9b74871cd8cc4b273
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,137 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
// Слой приложения: сообщает модулям, какие здания стоят в городе.
|
||||
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
|
||||
// - Дома (здание "Население") -> модуль Населения (предел жилья);
|
||||
// - Склады и Амбары -> лимиты склада в Экономике.
|
||||
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
|
||||
//
|
||||
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
|
||||
// (два одинаковых замера подряд) -- чтобы не поймать город посреди восстановления из сохранения.
|
||||
public class CitySync : MonoBehaviour
|
||||
{
|
||||
const float Interval = 0.5f;
|
||||
|
||||
public static CitySync Instance { get; private set; }
|
||||
|
||||
float next;
|
||||
string pending; // замер, который ждёт подтверждения
|
||||
string applied; // последний применённый замер
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Create()
|
||||
{
|
||||
if (Instance != null) return;
|
||||
var go = new GameObject("CitySync");
|
||||
DontDestroyOnLoad(go);
|
||||
Instance = go.AddComponent<CitySync>();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + Interval;
|
||||
if (BuildManager.Instance == null) return; // город не открыт -- модули помнят последнее
|
||||
|
||||
var city = Scan();
|
||||
if (city.Signature == applied) { pending = null; return; }
|
||||
if (city.Signature != pending) { pending = city.Signature; return; } // ждём подтверждения
|
||||
Apply(city);
|
||||
}
|
||||
|
||||
// Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места)
|
||||
public void SyncNow()
|
||||
{
|
||||
if (BuildManager.Instance == null) return;
|
||||
Apply(Scan());
|
||||
}
|
||||
|
||||
class City
|
||||
{
|
||||
public readonly List<KeyValuePair<WorkJob, int>> Workplaces = new List<KeyValuePair<WorkJob, int>>();
|
||||
public int HouseLevels, WarehouseLevels, GranaryLevels;
|
||||
public string Signature;
|
||||
}
|
||||
|
||||
static City Scan()
|
||||
{
|
||||
var city = new City();
|
||||
var sig = new List<string>();
|
||||
foreach (var placed in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре
|
||||
|
||||
var rc = placed.GetComponent<ResourceCollector>();
|
||||
if (rc != null)
|
||||
{
|
||||
if (WorkJobs.TryFromResource(rc.resourceType, out WorkJob job))
|
||||
{
|
||||
city.Workplaces.Add(new KeyValuePair<WorkJob, int>(job, rc.level));
|
||||
sig.Add("w" + (int)job + ":" + rc.level);
|
||||
}
|
||||
else if (rc.IsHouse)
|
||||
{
|
||||
city.HouseLevels += Mathf.Max(1, rc.level);
|
||||
sig.Add("h:" + rc.level);
|
||||
}
|
||||
}
|
||||
|
||||
var sb = placed.GetComponent<StorageBuilding>();
|
||||
if (sb != null)
|
||||
{
|
||||
if (sb.kind == StorageBuilding.Kind.Warehouse) city.WarehouseLevels += Mathf.Max(1, sb.level);
|
||||
else city.GranaryLevels += Mathf.Max(1, sb.level);
|
||||
sig.Add("s" + (int)sb.kind + ":" + sb.level);
|
||||
}
|
||||
}
|
||||
sig.Sort(System.StringComparer.Ordinal);
|
||||
var text = new StringBuilder();
|
||||
foreach (var s in sig) text.Append(s).Append(';');
|
||||
city.Signature = text.ToString();
|
||||
return city;
|
||||
}
|
||||
|
||||
void Apply(City city)
|
||||
{
|
||||
applied = city.Signature;
|
||||
pending = null;
|
||||
|
||||
var config = EconomyConfig.Load();
|
||||
|
||||
// Лимиты склада
|
||||
var rm = ResourceManager.Instance;
|
||||
if (rm != null)
|
||||
{
|
||||
ResourceType[] stored = { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Food, ResourceType.Gems };
|
||||
foreach (var t in stored) rm.SetCapacity(t, config.GetCapacity(t, city.WarehouseLevels, city.GranaryLevels));
|
||||
}
|
||||
|
||||
var production = GameModules.Production;
|
||||
var population = GameModules.Population;
|
||||
if (production == null || population == null) return;
|
||||
|
||||
// Места для рабочих: где мест стало больше (построили, улучшили) -- ставим туда свободных
|
||||
var before = new int[WorkJobs.Count];
|
||||
for (int j = 0; j < WorkJobs.Count; j++) before[j] = production.Slots((WorkJob)j);
|
||||
production.SetBuildings(city.Workplaces);
|
||||
population.SetHouseLevels(city.HouseLevels);
|
||||
population.ClampJobs();
|
||||
|
||||
if (!population.JobsInitialized)
|
||||
{
|
||||
population.AutoFillAll(); // первый запуск после этапа 3 или новая игра
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
if (production.Slots((WorkJob)j) > before[j]) population.AutoFill((WorkJob)j);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbcccbb94e6f46c89d4c49bf589b9f19
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,156 +0,0 @@
|
||||
#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(); });
|
||||
|
||||
var row4 = UIKit.Row(card, 70, 10);
|
||||
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
|
||||
|
||||
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.FreeWorkers} из {p.Workforce}; воины {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 IProduction prod))
|
||||
sb.Append($"Добыча в час: еда +{prod.RatePerHour(ResourceType.Food):0.#}, дерево +{prod.RatePerHour(ResourceType.Wood):0.#}, " +
|
||||
$"камень +{prod.RatePerHour(ResourceType.Stone):0.#}, металл +{prod.RatePerHour(ResourceType.Iron):0.#}, золото +{prod.RatePerHour(ResourceType.Gold):0.#}\n");
|
||||
|
||||
if (GameServices.TryGet(out IEconomy e))
|
||||
sb.Append($"Склад: еда {e.Get(ResourceType.Food):0}/{Cap(e, ResourceType.Food)} дерево {e.Get(ResourceType.Wood):0}/{Cap(e, ResourceType.Wood)} " +
|
||||
$"камень {e.Get(ResourceType.Stone):0}/{Cap(e, ResourceType.Stone)}\n");
|
||||
else sb.Append("Экономика не найдена (нет ResourceManager на сцене)\n");
|
||||
|
||||
long off = GameClock.OffsetSeconds;
|
||||
sb.Append(off > 0 ? $"Перемотано: +{off / 3600f:0.#} ч" : "Время: реальное");
|
||||
info.text = sb.ToString();
|
||||
}
|
||||
|
||||
static string Cap(IEconomy e, ResourceType t)
|
||||
{
|
||||
float c = e.GetCapacity(t);
|
||||
return float.IsInfinity(c) ? "∞" : c.ToString("0");
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2cba3db366a45feaaa3e1f4867ae423
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,208 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА БАЛАНСА ЭКОНОМИКИ. Все числа экономики -- здесь, а не в коде модулей.
|
||||
//
|
||||
// Ассет: Assets/Resources/EconomyConfig.asset (выдели его в окне Project и меняй числа в Inspector,
|
||||
// перекомпиляция не нужна). Создать заново: правый клик в Project -> Create -> Bogatyri -> Economy Config,
|
||||
// имя файла -- EconomyConfig, папка -- Resources.
|
||||
//
|
||||
// Модули конфиг не видят: App/GameModules переводит его в правила модулей (например, правила Населения).
|
||||
// Числа -- из дизайн-документа. Помеченные "не утверждено" -- временные, их решает команда.
|
||||
[CreateAssetMenu(fileName = "EconomyConfig", menuName = "Bogatyri/Economy Config")]
|
||||
public class EconomyConfig : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public class PopulationSettings
|
||||
{
|
||||
[Tooltip("Стартовый город T1: всего людей")]
|
||||
public int startTotal = 300;
|
||||
[Tooltip("Из них рабочих")]
|
||||
public int startWorkers = 120;
|
||||
[Tooltip("Из них воинов (выдаются в Армию пехотой при новой игре)")]
|
||||
public int startSoldiers = 30;
|
||||
|
||||
[Tooltip("Еды в час на одного жителя")]
|
||||
public float foodPerCivilianPerHour = 0.1f;
|
||||
[Tooltip("Еды в час на одного воина")]
|
||||
public float foodPerSoldierPerHour = 0.2f;
|
||||
|
||||
[Tooltip("Прирост людей в час, если есть еда (документ: 2–3)")]
|
||||
public float growthPerHour = 2.5f;
|
||||
|
||||
[Tooltip("Предел населения без Домов на 1 уровне Ратуши (решение команды от 19.10: 300 при 300 -- прирост стоит)")]
|
||||
public int housingBase = 300;
|
||||
[Tooltip("Прибавка к пределу за каждый следующий уровень Ратуши. Не утверждено")]
|
||||
public int housingPerTownHallLevel = 50;
|
||||
[Tooltip("Прибавка к пределу за каждый уровень Дома (здание \"Население\"). Не утверждено")]
|
||||
public int housingPerHouseLevel = 50;
|
||||
|
||||
[Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")]
|
||||
public int maxOfflineHours = 48;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ProductionSettings // этап 3: производство через рабочих
|
||||
{
|
||||
[Tooltip("Лесоруб T1: дерева в час")]
|
||||
public float woodcutterPerHour = 2f;
|
||||
[Tooltip("Фермер T1: еды в час")]
|
||||
public float farmerPerHour = 2f;
|
||||
[Tooltip("Каменотёс T1: камня в час")]
|
||||
public float stonecutterPerHour = 1.5f;
|
||||
[Tooltip("Шахтёр T1: металла в час")]
|
||||
public float minerIronPerHour = 1f;
|
||||
[Tooltip("Шахтёр T1: золота в час")]
|
||||
public float minerGoldPerHour = 0.4f;
|
||||
[Tooltip("Мест для рабочих в здании T1 (Лесопилка и т.п.)")]
|
||||
public int workerSlotsT1 = 30;
|
||||
[Tooltip("+ мест за каждый уровень здания выше первого. Не утверждено")]
|
||||
public int workerSlotsPerLevel = 10;
|
||||
[Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")]
|
||||
public float efficiencyPerLevel = 0.1f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StorageSettings // этап 1: лимиты хранения
|
||||
{
|
||||
[Tooltip("Лимит без Складов: дерево, камень, металл, золото. Не утверждено")]
|
||||
public float baseGoods = 1000f;
|
||||
[Tooltip("Лимит еды без Амбаров. Не утверждено")]
|
||||
public float baseFood = 1500f;
|
||||
[Tooltip("+ к лимиту дерева, камня, металла, золота за каждый уровень Склада. Не утверждено")]
|
||||
public float perWarehouseLevel = 1000f;
|
||||
[Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")]
|
||||
public float perGranaryLevel = 1500f;
|
||||
[Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")]
|
||||
public float crystalCap = 50f;
|
||||
|
||||
[Tooltip("Цена Склада: дерево / камень. Не утверждено")]
|
||||
public float warehouseWood = 200f;
|
||||
public float warehouseStone = 100f;
|
||||
[Tooltip("Цена Амбара: дерево / камень. Не утверждено")]
|
||||
public float granaryWood = 150f;
|
||||
public float granaryStone = 50f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class UpgradeSettings // улучшение зданий (рабочие места, Дома, Склады, Амбары)
|
||||
{
|
||||
[Tooltip("Цена улучшения с 1 на 2 уровень: дерево / камень. Не утверждено")]
|
||||
public float baseWood = 150f;
|
||||
public float baseStone = 100f;
|
||||
[Tooltip("Во сколько раз дорожает каждый следующий уровень. Не утверждено")]
|
||||
public float multiplierPerLevel = 1.6f;
|
||||
public int maxLevel = 10;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class DepositSettings // этап 4: качество месторождения (пока не используется)
|
||||
{
|
||||
public float poor = 0.6f;
|
||||
public float normal = 1f;
|
||||
public float rich = 1.5f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SoldierSettings // этап 5: цена солдата (пока не используется)
|
||||
{
|
||||
public int people = 1;
|
||||
public float food = 15f;
|
||||
public float iron = 5f;
|
||||
public float gold = 5f;
|
||||
[Tooltip("Время подготовки, секунд. Не утверждено")]
|
||||
public int trainSeconds = 60;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StartResources
|
||||
{
|
||||
[Tooltip("Ресурсы новой игры. Не утверждено")]
|
||||
public float wood = 500f;
|
||||
public float food = 500f;
|
||||
public float stone = 200f;
|
||||
public float iron = 100f;
|
||||
public float gold = 100f;
|
||||
public float gems = 0f;
|
||||
}
|
||||
|
||||
[Header("Население (этап 2)")]
|
||||
public PopulationSettings population = new PopulationSettings();
|
||||
|
||||
[Header("Стартовые ресурсы новой игры")]
|
||||
public StartResources startResources = new StartResources();
|
||||
|
||||
[Header("Склады и лимиты (этап 1)")]
|
||||
public StorageSettings storage = new StorageSettings();
|
||||
|
||||
[Header("Производство T1 (этап 3)")]
|
||||
public ProductionSettings production = new ProductionSettings();
|
||||
|
||||
[Header("Улучшение зданий")]
|
||||
public UpgradeSettings upgrade = new UpgradeSettings();
|
||||
|
||||
[Header("Месторождения (этап 4)")]
|
||||
public DepositSettings deposits = new DepositSettings();
|
||||
|
||||
[Header("Солдат T1 (этап 5)")]
|
||||
public SoldierSettings soldier = new SoldierSettings();
|
||||
|
||||
// ---------------- Загрузка ----------------
|
||||
|
||||
static EconomyConfig cached;
|
||||
|
||||
// Конфиг из Assets/Resources/EconomyConfig.asset. Нет ассета -- числа по умолчанию (из этого файла) и предупреждение.
|
||||
public static EconomyConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<EconomyConfig>("EconomyConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[ЭКОНОМИКА] Нет Assets/Resources/EconomyConfig.asset -- берутся числа по умолчанию из EconomyConfig.cs.");
|
||||
cached = CreateInstance<EconomyConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Лимит склада для ресурса по уровням Складов и Амбаров. PositiveInfinity -- без лимита (люди, сила)
|
||||
public float GetCapacity(ResourceType type, int warehouseLevels, int granaryLevels)
|
||||
{
|
||||
var s = storage;
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood:
|
||||
case ResourceType.Stone:
|
||||
case ResourceType.Iron:
|
||||
case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels;
|
||||
case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels;
|
||||
case ResourceType.Gems: return s.crystalCap;
|
||||
default: return float.PositiveInfinity;
|
||||
}
|
||||
}
|
||||
|
||||
// Цена улучшения здания с уровня level на level + 1
|
||||
public ResourceAmount[] UpgradeCost(int level)
|
||||
{
|
||||
float m = Mathf.Pow(upgrade.multiplierPerLevel, Mathf.Max(0, level - 1));
|
||||
return new[]
|
||||
{
|
||||
new ResourceAmount(ResourceType.Wood, Mathf.Round(upgrade.baseWood * m)),
|
||||
new ResourceAmount(ResourceType.Stone, Mathf.Round(upgrade.baseStone * m)),
|
||||
};
|
||||
}
|
||||
|
||||
public float GetStartResource(ResourceType type)
|
||||
{
|
||||
var s = startResources;
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood: return s.wood;
|
||||
case ResourceType.Food: return s.food;
|
||||
case ResourceType.Stone: return s.stone;
|
||||
case ResourceType.Iron: return s.iron;
|
||||
case ResourceType.Gold: return s.gold;
|
||||
case ResourceType.Gems: return s.gems;
|
||||
default: return 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ddb59400c6d4b08b3afaaadc461ec1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,10 +8,6 @@ using UnityEngine;
|
||||
// Модули друг о друге ничего не знают -- всю "проводку" видно в одном файле.
|
||||
public static class GameModules
|
||||
{
|
||||
// Модули экономики для слоя приложения (окна, синхронизация города). Модули друг друга так не берут -- только через контракты.
|
||||
public static PopulationService Population { get; private set; }
|
||||
public static ProductionService Production { get; private set; }
|
||||
|
||||
// После SubsystemRegistration (там ядро очищается) и раньше любых Awake
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||||
static void Register()
|
||||
@@ -26,35 +22,6 @@ 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));
|
||||
Population = population;
|
||||
GameServices.Register<IPopulation>(population);
|
||||
SaveRegistry.Register(population);
|
||||
|
||||
// Добыча через рабочих -- чистый C# (секция "production"). Какие здания стоят, сообщает App/CitySync.
|
||||
var production = new ProductionService(ToProductionRules(config));
|
||||
Production = production;
|
||||
GameServices.Register<IProduction>(production);
|
||||
SaveRegistry.Register(production);
|
||||
|
||||
// Склад и Амбар появляются в списке стройки кодом -- до восстановления зданий из сохранения
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => StorageBootstrap.Ensure());
|
||||
|
||||
// Тап по зданию -> окно здания и рабочих (статические события переживают Play в редакторе -- сначала отписка)
|
||||
ResourceCollector.Tapped -= BuildingWindow.OnWorkplaceTapped;
|
||||
ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped;
|
||||
StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped;
|
||||
StorageBuilding.Tapped += BuildingWindow.OnStorageTapped;
|
||||
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);
|
||||
@@ -130,67 +97,6 @@ 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,
|
||||
HousingPerHouseLevel = p.housingPerHouseLevel,
|
||||
MaxOfflineHours = p.maxOfflineHours
|
||||
};
|
||||
}
|
||||
|
||||
static ProductionRules ToProductionRules(EconomyConfig config)
|
||||
{
|
||||
var p = config.production;
|
||||
return new ProductionRules
|
||||
{
|
||||
// порядок как в WorkJob: лесоруб, фермер, каменотёс, шахтёр-металл, шахтёр-золото
|
||||
PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour },
|
||||
SlotsPerBuilding = p.workerSlotsT1,
|
||||
SlotsPerLevel = p.workerSlotsPerLevel,
|
||||
EfficiencyPerLevel = p.efficiencyPerLevel
|
||||
};
|
||||
}
|
||||
|
||||
// Новая игра: стартовое войско (пехота) и стартовые ресурсы из 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;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d7b4f971ff440eaa5744fbe2a1e834d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,85 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Добавляет "Склад" и "Амбар" в список стройки кодом -- как Казармы (Meta/Build/BarracksBootstrap).
|
||||
// Шаблон -- копия обычного здания без добычи; пока нет арта, отличаем цветом.
|
||||
// Вызывается при загрузке сцены и перед восстановлением города из сохранения (SettlementRestoringEvent),
|
||||
// иначе построенные Склады не нашлись бы при загрузке.
|
||||
public static class StorageBootstrap
|
||||
{
|
||||
public const string WarehouseName = "Склад";
|
||||
public const string GranaryName = "Амбар";
|
||||
|
||||
static GameObject warehouseTemplate;
|
||||
static GameObject granaryTemplate;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Init()
|
||||
{
|
||||
Ensure();
|
||||
UnityEngine.SceneManagement.SceneManager.sceneLoaded += (s, m) => Ensure();
|
||||
}
|
||||
|
||||
public static void Ensure()
|
||||
{
|
||||
var bm = BuildManager.Instance != null ? BuildManager.Instance : Object.FindAnyObjectByType<BuildManager>();
|
||||
if (bm == null || bm.allBuildings == null) return;
|
||||
|
||||
bool hasWarehouse = false, hasGranary = false;
|
||||
BuildingData template = null;
|
||||
foreach (var b in bm.allBuildings)
|
||||
{
|
||||
if (b == null) continue;
|
||||
if (b.buildingName == WarehouseName) hasWarehouse = true;
|
||||
if (b.buildingName == GranaryName) hasGranary = true;
|
||||
if (template == null && b.prefab != null && b.prefab.GetComponent<ResourceCollector>() != null) template = b;
|
||||
}
|
||||
if (hasWarehouse && hasGranary) return;
|
||||
if (template == null) { Debug.LogWarning("[СКЛАДЫ] Нет здания-шаблона в BuildManager"); return; }
|
||||
|
||||
var cfg = EconomyConfig.Load().storage;
|
||||
if (!hasWarehouse)
|
||||
{
|
||||
if (warehouseTemplate == null) warehouseTemplate = MakeTemplate(template.prefab, "Warehouse", StorageBuilding.Kind.Warehouse, new Color(0.85f, 0.7f, 0.5f));
|
||||
bm.allBuildings.Add(MakeData(WarehouseName, template, warehouseTemplate, cfg.warehouseWood, cfg.warehouseStone));
|
||||
}
|
||||
if (!hasGranary)
|
||||
{
|
||||
if (granaryTemplate == null) granaryTemplate = MakeTemplate(template.prefab, "Granary", StorageBuilding.Kind.Granary, new Color(1f, 0.92f, 0.55f));
|
||||
bm.allBuildings.Add(MakeData(GranaryName, template, granaryTemplate, cfg.granaryWood, cfg.granaryStone));
|
||||
}
|
||||
Debug.Log("[СКЛАДЫ] Склад и Амбар добавлены в список стройки");
|
||||
}
|
||||
|
||||
static GameObject MakeTemplate(GameObject source, string name, StorageBuilding.Kind kind, Color tint)
|
||||
{
|
||||
var t = Object.Instantiate(source);
|
||||
t.name = name;
|
||||
t.SetActive(false);
|
||||
Object.DontDestroyOnLoad(t);
|
||||
foreach (var c in t.GetComponents<ResourceCollector>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in t.GetComponents<ResourceProducer>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in t.GetComponents<BuildingUI>()) Object.DestroyImmediate(c);
|
||||
for (int i = t.transform.childCount - 1; i >= 0; i--)
|
||||
Object.DestroyImmediate(t.transform.GetChild(i).gameObject); // полоска над зданием не нужна
|
||||
var sr = t.GetComponent<SpriteRenderer>();
|
||||
if (sr != null) sr.color = tint;
|
||||
t.AddComponent<StorageBuilding>().kind = kind;
|
||||
return t;
|
||||
}
|
||||
|
||||
static BuildingData MakeData(string title, BuildingData template, GameObject prefab, float wood, float stone)
|
||||
{
|
||||
var data = ScriptableObject.CreateInstance<BuildingData>();
|
||||
data.name = "Data_" + prefab.name + "_Runtime";
|
||||
data.buildingName = title;
|
||||
data.icon = template.icon;
|
||||
data.prefab = prefab;
|
||||
data.requiredTownHallLevel = 1;
|
||||
data.cost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = wood },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = stone },
|
||||
};
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87b085cfbef5449b97456bea718c7466
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// Склад или Амбар (этап 1): каждый уровень поднимает лимит хранения.
|
||||
// Склад -- дерево, камень, металл, золото; Амбар -- еда. Числа -- EconomyConfig, раздел "Склады и лимиты".
|
||||
// Здание появляется в списке стройки кодом (App/StorageBootstrap), отдельных префабов пока нет.
|
||||
// Уровень сохраняет модуль Поселения (ILeveledBuilding), лимиты считает App/CitySync.
|
||||
public class StorageBuilding : MonoBehaviour, ILeveledBuilding
|
||||
{
|
||||
public enum Kind { Warehouse, Granary }
|
||||
|
||||
public static event Action<StorageBuilding> Tapped;
|
||||
|
||||
public Kind kind;
|
||||
public int level = 1;
|
||||
|
||||
public int Level { get => level; set => level = Mathf.Max(1, value); }
|
||||
|
||||
public string Title => kind == Kind.Warehouse ? StorageBootstrap.WarehouseName : StorageBootstrap.GranaryName;
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап, а не перетаскивание камеры
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
Tapped?.Invoke(this);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bea97fb12da84c7daeb07f65d4947cae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -2,9 +2,6 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// Полоска над зданием (этап 3): рабочие этой профессии / места в городе.
|
||||
// Раньше показывала накопленный запас кликера -- кликер убран, добычу дают рабочие.
|
||||
// У Дома и Кристалла рабочих нет -- там подпись уровня, полоска скрыта.
|
||||
public class BuildingUI : MonoBehaviour
|
||||
{
|
||||
[Header("Настройки")]
|
||||
@@ -12,8 +9,6 @@ public class BuildingUI : MonoBehaviour
|
||||
public TMP_Text stokText;
|
||||
public Slider progressBar;
|
||||
|
||||
float next;
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (building == null)
|
||||
@@ -27,25 +22,19 @@ public class BuildingUI : MonoBehaviour
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
void UpdateUI()
|
||||
{
|
||||
bool workplace = building.IsWorkplace;
|
||||
if (progressBar != null && progressBar.gameObject.activeSelf != workplace) progressBar.gameObject.SetActive(workplace);
|
||||
|
||||
if (!workplace)
|
||||
if (stokText != null)
|
||||
{
|
||||
if (stokText != null) stokText.text = building.IsHouse ? $"Дом {building.level} ур." : $"{building.level} ур.";
|
||||
return;
|
||||
stokText.text = $"{building.GetCurrentStock():F1} / {building.GetMaxCapacity()}";
|
||||
}
|
||||
if (progressBar != null)
|
||||
{
|
||||
float fill = building.GetCurrentStock() / building.GetMaxCapacity();
|
||||
progressBar.value = Mathf.Clamp01(fill);
|
||||
}
|
||||
|
||||
float workers = building.GetCurrentStock();
|
||||
float slots = building.GetMaxCapacity();
|
||||
if (stokText != null) stokText.text = $"Рабочие {workers:0} / {slots:0}";
|
||||
if (progressBar != null) progressBar.value = slots > 0 ? Mathf.Clamp01(workers / slots) : 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,71 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
|
||||
// Ресурс на верхней панели (этап 1): "запас / лимит" и прирост в час.
|
||||
// - Дерево, камень, металл, золото, еда, кристаллы: "820 / 1000" и строкой ниже "+50/ч" (у еды -- добыча минус расход);
|
||||
// - Люди: "300 / 350" (население / предел жилья) и прирост людей в час.
|
||||
// Числа без сокращений 1.2K / 1.5M: по документу нормальный масштаб -- сотни и тысячи.
|
||||
|
||||
[RequireComponent(typeof(TMP_Text))]
|
||||
public class ResourceUIText : MonoBehaviour
|
||||
{
|
||||
public ResourceType resourceType;
|
||||
|
||||
[Tooltip("Показывать лимит склада: \"820 / 1000\"")]
|
||||
public bool showCapacity = true;
|
||||
|
||||
[Tooltip("Показывать прирост в час второй строкой: \"+50/ч\"")]
|
||||
public bool showRate = true;
|
||||
|
||||
[Tooltip("Устарело (этап 1): сокращения 1.2K / 1.5M убраны")]
|
||||
[HideInInspector] public bool useShortFormat;
|
||||
[Tooltip("Сокращать большие числа (1200 -> 1.2K, 1500000 -> 1.5M), как на референсе HUD")]
|
||||
public bool useShortFormat = true;
|
||||
|
||||
private TMP_Text label;
|
||||
private float next;
|
||||
private string lastText;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
label = GetComponent<TMP_Text>();
|
||||
|
||||
// В сцене на ячейках дерева, камня, металла, золота и кристалла по ошибке висит второй такой же компонент
|
||||
// с типом "Люди" -- он перебивал число ресурса. Если рядом есть компонент с настоящим ресурсом -- выключаемся.
|
||||
if (resourceType == ResourceType.Population)
|
||||
{
|
||||
foreach (var other in GetComponents<ResourceUIText>())
|
||||
{
|
||||
if (other != this && other.resourceType != ResourceType.Population)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
next = 0f;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
string text = BuildText();
|
||||
if (text == lastText) return;
|
||||
lastText = text;
|
||||
label.text = text;
|
||||
}
|
||||
|
||||
string BuildText()
|
||||
{
|
||||
// Люди -- из модуля Населения
|
||||
if (resourceType == ResourceType.Population && GameServices.TryGet(out IPopulation pop))
|
||||
// Ждём, пока ResourceManager точно проинициализируется (Awake порядок не гарантирован)
|
||||
if (ResourceManager.Instance != null)
|
||||
{
|
||||
string value = showCapacity ? $"{pop.Total} / {pop.HousingCap}" : pop.Total.ToString();
|
||||
return showRate ? value + RateLine(pop.GrowthPerHour) : value;
|
||||
Subscribe();
|
||||
}
|
||||
else
|
||||
{
|
||||
Invoke(nameof(TrySubscribeDelayed), 0.1f);
|
||||
}
|
||||
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return "0";
|
||||
int amount = Mathf.FloorToInt(economy.Get(resourceType));
|
||||
float cap = economy.GetCapacity(resourceType);
|
||||
string main = showCapacity && !float.IsInfinity(cap) ? $"{amount} / {Mathf.RoundToInt(cap)}" : amount.ToString();
|
||||
if (!showRate || resourceType == ResourceType.Power) return main;
|
||||
|
||||
float rate = GameServices.TryGet(out IProduction production) ? production.RatePerHour(resourceType) : 0f;
|
||||
if (resourceType == ResourceType.Food && GameServices.TryGet(out IPopulation p)) rate -= p.FoodPerHour;
|
||||
if (resourceType == ResourceType.Gems) return main; // кристалл не добывается рабочими (разломы на карте мира)
|
||||
return main + RateLine(rate);
|
||||
}
|
||||
|
||||
static string RateLine(float rate)
|
||||
void TrySubscribeDelayed()
|
||||
{
|
||||
string color = rate < 0 ? "#ff8a80" : "#9be29b";
|
||||
string sign = rate > 0 ? "+" : "";
|
||||
return $"\n<size=60%><color={color}>{sign}{rate:0.#}/ч</color></size>";
|
||||
if (ResourceManager.Instance != null)
|
||||
Subscribe();
|
||||
}
|
||||
|
||||
void Subscribe()
|
||||
{
|
||||
ResourceManager.Instance.OnResourceChanged += HandleResourceChanged;
|
||||
// сразу показать текущее значение при старте, не дожидаясь первого изменения
|
||||
UpdateText(ResourceManager.Instance.GetResource(resourceType));
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
if (ResourceManager.Instance != null)
|
||||
ResourceManager.Instance.OnResourceChanged -= HandleResourceChanged;
|
||||
}
|
||||
|
||||
void HandleResourceChanged(ResourceType type, float newValue)
|
||||
{
|
||||
if (type != resourceType) return; // реагируем только на "свой" ресурс
|
||||
UpdateText(newValue);
|
||||
}
|
||||
|
||||
void UpdateText(float value)
|
||||
{
|
||||
label.text = useShortFormat ? FormatShort(value) : Mathf.FloorToInt(value).ToString();
|
||||
}
|
||||
|
||||
string FormatShort(float value)
|
||||
{
|
||||
if (value >= 1_000_000f)
|
||||
return (value / 1_000_000f).ToString("0.#") + "M";
|
||||
if (value >= 1_000f)
|
||||
return (value / 1_000f).ToString("0.#") + "K";
|
||||
return Mathf.FloorToInt(value).ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ public static class CoreUnityBootstrap
|
||||
GameServices.Clear();
|
||||
EventBus.Clear();
|
||||
SaveRegistry.Clear();
|
||||
GameClock.ResetOffset(); // отладочная перемотка не переживает перезапуск
|
||||
EventBus.OnHandlerError = Debug.LogException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,20 +16,6 @@ public interface IEconomy
|
||||
bool CanAfford(IReadOnlyList<ResourceAmount> cost);
|
||||
// Списывает всё или ничего
|
||||
bool TrySpend(IReadOnlyList<ResourceAmount> cost);
|
||||
|
||||
// Лимит склада для ресурса. float.PositiveInfinity -- без лимита (люди, сила).
|
||||
// Сверх лимита ресурс не прибавляется (добыча, награды) -- лишнее пропадает.
|
||||
float GetCapacity(ResourceType type);
|
||||
}
|
||||
|
||||
// Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService).
|
||||
public interface IProduction
|
||||
{
|
||||
int Slots(WorkJob job); // мест для рабочих этой профессии во всех зданиях города
|
||||
float Efficiency(WorkJob job); // множитель от уровня зданий (1 -- здания 1 уровня)
|
||||
float RatePerHour(ResourceType type); // добыча ресурса в час сейчас
|
||||
// Добыть за hours часов (вызывает модуль Населения в своём расчёте -- сначала добыли, потом съели)
|
||||
void Produce(double hours);
|
||||
}
|
||||
|
||||
// ---------------- Поселение: здания и развитие города ----------------
|
||||
@@ -46,31 +32,6 @@ public interface ILeveledBuilding
|
||||
int Level { get; set; }
|
||||
}
|
||||
|
||||
// ---------------- Население: жители, еда, прирост ----------------
|
||||
public interface IPopulation
|
||||
{
|
||||
int Total { get; } // все люди города: жители + воины (в городе и в походах)
|
||||
int Civilians { get; } // жители (не воины)
|
||||
int Workers { get; } // жители на работе (все профессии вместе)
|
||||
int Idle { get; } // остальные жители: Civilians - Workers
|
||||
int Workforce { get; } // сколько жителей вообще могут работать (взрослые; стартовый город -- 120)
|
||||
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
|
||||
int Soldiers { get; } // воины -- их хранит модуль Армии
|
||||
int HousingCap { get; } // предел населения: выше него прирост не идёт
|
||||
|
||||
float FoodPerHour { get; } // сколько город съедает в час
|
||||
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
|
||||
bool IsStarving { get; } // еды не хватило на последний расчёт
|
||||
|
||||
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
|
||||
void Tick(long unixNow);
|
||||
|
||||
// Рабочие по профессиям (этап 3)
|
||||
int GetWorkers(WorkJob job);
|
||||
// Поставить count рабочих на профессию (в пределах мест в зданиях и свободных рабочих). Возвращает, сколько стоит теперь.
|
||||
int SetWorkers(WorkJob job, int count);
|
||||
}
|
||||
|
||||
// ---------------- Армия: войска ----------------
|
||||
public interface IArmy
|
||||
{
|
||||
|
||||
@@ -24,37 +24,6 @@ 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;
|
||||
}
|
||||
|
||||
// Рабочих переставили (окно "Рабочие", автоматическое заполнение новых мест)
|
||||
public struct WorkersChangedEvent
|
||||
{
|
||||
public int Workers;
|
||||
public int FreeWorkers;
|
||||
}
|
||||
|
||||
// Голод начался (IsStarving = true) или закончился
|
||||
public struct StarvationChangedEvent
|
||||
{
|
||||
public bool IsStarving;
|
||||
}
|
||||
|
||||
// ---------------- Армия ----------------
|
||||
// Число воинов изменилось (обучение, потери, поход, возвращение, загрузка)
|
||||
public struct TroopsChangedEvent
|
||||
@@ -116,8 +85,6 @@ public struct CircleLeftEvent
|
||||
|
||||
// ---------------- Сохранения ----------------
|
||||
public struct GameLoadedEvent { }
|
||||
// Файла сохранения нет -- это первая игра на устройстве (стартовые ресурсы, стартовое войско)
|
||||
public struct NewGameStartedEvent { }
|
||||
public struct GameSavedEvent { }
|
||||
// Сохранения сброшены, все модули уже в состоянии новой игры
|
||||
public struct GameResetEvent { }
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 03f995a4fe964f48bfe8d7b2f7215ecc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,53 +0,0 @@
|
||||
// Профессии рабочих. Рабочего держит модуль Населения, добычу считает модуль Экономики.
|
||||
// Числовые значения НЕ менять: они записаны в сохранениях.
|
||||
public enum WorkJob
|
||||
{
|
||||
Woodcutter = 0, // лесоруб -> дерево (Лесопилка)
|
||||
Farmer = 1, // фермер -> еда (Ферма)
|
||||
Stonecutter = 2, // каменотёс -> камень (Каменоломня)
|
||||
MinerIron = 3, // шахтёр -> металл (Шахта)
|
||||
MinerGold = 4 // шахтёр -> золото (Золотой рудник)
|
||||
}
|
||||
|
||||
public static class WorkJobs
|
||||
{
|
||||
public const int Count = 5;
|
||||
|
||||
public static ResourceType Resource(WorkJob job)
|
||||
{
|
||||
switch (job)
|
||||
{
|
||||
case WorkJob.Woodcutter: return ResourceType.Wood;
|
||||
case WorkJob.Farmer: return ResourceType.Food;
|
||||
case WorkJob.Stonecutter: return ResourceType.Stone;
|
||||
case WorkJob.MinerIron: return ResourceType.Iron;
|
||||
default: return ResourceType.Gold;
|
||||
}
|
||||
}
|
||||
|
||||
// Какая профессия добывает этот ресурс. false -- ресурс не добывается рабочими (люди, кристаллы, сила)
|
||||
public static bool TryFromResource(ResourceType type, out WorkJob job)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood: job = WorkJob.Woodcutter; return true;
|
||||
case ResourceType.Food: job = WorkJob.Farmer; return true;
|
||||
case ResourceType.Stone: job = WorkJob.Stonecutter; return true;
|
||||
case ResourceType.Iron: job = WorkJob.MinerIron; return true;
|
||||
case ResourceType.Gold: job = WorkJob.MinerGold; return true;
|
||||
default: job = WorkJob.Woodcutter; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Name(WorkJob job)
|
||||
{
|
||||
switch (job)
|
||||
{
|
||||
case WorkJob.Woodcutter: return "Лесорубы";
|
||||
case WorkJob.Farmer: return "Фермеры";
|
||||
case WorkJob.Stonecutter: return "Каменотёсы";
|
||||
case WorkJob.MinerIron: return "Шахтёры (металл)";
|
||||
default: return "Шахтёры (золото)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb271faca9634ee0b607250e9341b0ce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,71 +1,100 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// МОДУЛЬ ЭКОНОМИКИ: здание-рабочее место (этап 3). Висит на всех добывающих зданиях (префабы Лес, Еда, Камень...).
|
||||
//
|
||||
// Раньше это был кликер (тап -- забрать накопленное). Теперь ресурс производят люди:
|
||||
// здание только даёт места для рабочих и эффективность уровня, а добычу считает ProductionService.
|
||||
// Имя класса оставлено старым, чтобы не перенастраивать префабы.
|
||||
//
|
||||
// resourceType = Wood/Food/Stone/Iron/Gold -- рабочее место этой профессии;
|
||||
// resourceType = Population -- Дом: прибавляет места к пределу жилья;
|
||||
// resourceType = Gems -- Кристалл: рабочих нет (синий кристалл добывается в разломах на карте мира).
|
||||
//
|
||||
// Тап по зданию открывает окно рабочих -- его рисует слой приложения (подписка на Tapped в App/GameModules).
|
||||
// Модуль Экономики: временная добыча по клику (уйдёт в этапе 3 -- производство через рабочих).
|
||||
public class ResourceCollector : MonoBehaviour, ILeveledBuilding
|
||||
{
|
||||
// Тап по зданию: интерфейс откроет окно "Рабочие" (модуль про интерфейс не знает)
|
||||
public static event Action<ResourceCollector> Tapped;
|
||||
|
||||
// Уровень здания для Поселения (оно сохраняет уровни, не зная, что это за здание)
|
||||
public int Level { get => level; set => level = Mathf.Max(1, value); }
|
||||
public int Level { get => level; set => level = value; }
|
||||
|
||||
[Header("Что добывают рабочие этого здания")]
|
||||
[Header("Что производит это здание")]
|
||||
public ResourceType resourceType;
|
||||
|
||||
[Header("Уровень здания (больше мест и эффективность)")]
|
||||
[Tooltip("База награды за клик (используется для расчета множителя уровня)")]
|
||||
public float amountPerClick = 10f;
|
||||
|
||||
[Header("Параметры хранилища")]
|
||||
[Tooltip("Максимальная вместимость")]
|
||||
public float maxCapacity = 100f;
|
||||
|
||||
[Tooltip("Текущий запас (меняется автоматически)")]
|
||||
public float currentStock = 0f;
|
||||
|
||||
[Header("Уровень здания")]
|
||||
public int level = 1;
|
||||
public float perLevelMultiplier = 1.2f;
|
||||
|
||||
[Header("Устарело (этап 3): кликер убран, поля оставлены для старых префабов")]
|
||||
[HideInInspector] public float amountPerClick = 10f;
|
||||
[HideInInspector] public float maxCapacity = 100f;
|
||||
[HideInInspector] public float currentStock = 0f;
|
||||
[HideInInspector] public float perLevelMultiplier = 1.2f;
|
||||
private ResourceManager _manager;
|
||||
private bool isRecharging = false;
|
||||
|
||||
public bool IsWorkplace => WorkJobs.TryFromResource(resourceType, out _);
|
||||
public bool IsHouse => resourceType == ResourceType.Population;
|
||||
void Awake()
|
||||
{
|
||||
_manager = ResourceManager.Instance;
|
||||
if (_manager == null)
|
||||
{
|
||||
Debug.LogError($"{gameObject.name}: ResourceManager не найден!");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
currentStock = 0f;
|
||||
}
|
||||
|
||||
// Для полоски над зданием (BuildingUI): рабочие этой профессии / места
|
||||
public float GetCurrentStock()
|
||||
{
|
||||
if (!WorkJobs.TryFromResource(resourceType, out WorkJob job)) return 0f;
|
||||
return GameServices.TryGet(out IPopulation population) ? population.GetWorkers(job) : 0f;
|
||||
return currentStock;
|
||||
}
|
||||
|
||||
public float GetMaxCapacity()
|
||||
{
|
||||
if (!WorkJobs.TryFromResource(resourceType, out WorkJob job)) return 0f;
|
||||
return GameServices.TryGet(out IProduction production) ? production.Slots(job) : 0f;
|
||||
return maxCapacity;
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен собирать ресурс под ней
|
||||
if (isRecharging)
|
||||
{
|
||||
Debug.Log($"[КЛИК] {gameObject.name}: уже идёт восстановление, ждём.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStock > 0)
|
||||
{
|
||||
float reward = currentStock; // берём всё, что накопилось
|
||||
_manager.AddResource(resourceType, reward);
|
||||
|
||||
currentStock = 0f;
|
||||
Debug.Log($"[СБОР] Получено {reward:F1} {resourceType}. Запас сброшен в 0.");
|
||||
|
||||
StartCoroutine(RechargeRoutine());
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[КЛИК] {gameObject.name} пуст, запускаем восстановление.");
|
||||
StartCoroutine(RechargeRoutine());
|
||||
}
|
||||
}
|
||||
|
||||
// Отпустил палец на здании, почти не сдвинув (а не тащил камеру) -- окно рабочих
|
||||
void OnMouseUpAsButton()
|
||||
System.Collections.IEnumerator RechargeRoutine()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на здание под ней
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
Tapped?.Invoke(this);
|
||||
if (isRecharging) yield break;
|
||||
isRecharging = true;
|
||||
|
||||
float rechargeRate = 5f; // единиц в секунду
|
||||
|
||||
while (currentStock < maxCapacity)
|
||||
{
|
||||
currentStock += rechargeRate * Time.deltaTime;
|
||||
if (currentStock > maxCapacity) currentStock = maxCapacity;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
isRecharging = false;
|
||||
Debug.Log($"[ГОТОВО] {gameObject.name} полностью заполнен!");
|
||||
}
|
||||
|
||||
public void Upgrade()
|
||||
{
|
||||
level++;
|
||||
Debug.Log($"[ЗДАНИЕ] {name}: уровень {level}");
|
||||
Debug.Log($"[АПГРЕЙД] Уровень {level}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
|
||||
private Dictionary<ResourceType, float> resources = new Dictionary<ResourceType, float>();
|
||||
|
||||
// Лимиты склада (этап 1). Нет записи -- лимита нет. Задаёт слой приложения (App/CitySync) по Складам и Амбарам,
|
||||
// запоминаются в сохранении: на карте мира город не открыт, а лимиты действуют.
|
||||
private Dictionary<ResourceType, float> capacities = new Dictionary<ResourceType, float>();
|
||||
|
||||
// Лимит изменился (для интерфейса: "запас / лимит")
|
||||
public event Action<ResourceType, float> OnCapacityChanged;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
// Классический паттерн Singleton: если уже есть экземпляр -- уничтожаем дубликат
|
||||
@@ -57,21 +50,14 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
SaveRegistry.Unregister(this);
|
||||
}
|
||||
|
||||
// Добавить ресурс (можно и отрицательное число -- для трат).
|
||||
// Прибавка не поднимает запас выше лимита склада -- лишнее пропадает. Если запас уже выше лимита
|
||||
// (старое сохранение, лимит уменьшился), он не урезается, просто больше не растёт.
|
||||
// Добавить ресурс (можно и отрицательное число -- для трат)
|
||||
public void AddResource(ResourceType type, float amount)
|
||||
{
|
||||
if (!resources.ContainsKey(type))
|
||||
resources[type] = 0f;
|
||||
|
||||
float current = resources[type];
|
||||
float next = current + amount;
|
||||
if (amount > 0f && capacities.TryGetValue(type, out float cap))
|
||||
next = Mathf.Min(next, Mathf.Max(current, cap));
|
||||
if (next < 0f) next = 0f; // ресурс не уходит в минус
|
||||
if (next == current) return; // ничего не изменилось (например, склад полон) -- не будим интерфейс
|
||||
resources[type] = next;
|
||||
resources[type] += amount;
|
||||
if (resources[type] < 0f) resources[type] = 0f; // ресурс не уходит в минус
|
||||
|
||||
NotifyChanged(type);
|
||||
}
|
||||
@@ -95,19 +81,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
return resources.TryGetValue(type, out float value) ? value : 0f;
|
||||
}
|
||||
|
||||
// Лимит склада: float.PositiveInfinity -- без лимита
|
||||
public float GetCapacity(ResourceType type)
|
||||
{
|
||||
return capacities.TryGetValue(type, out float cap) ? cap : float.PositiveInfinity;
|
||||
}
|
||||
|
||||
public void SetCapacity(ResourceType type, float capacity)
|
||||
{
|
||||
if (capacities.TryGetValue(type, out float old) && Mathf.Approximately(old, capacity)) return;
|
||||
capacities[type] = Mathf.Max(0f, capacity);
|
||||
OnCapacityChanged?.Invoke(type, capacities[type]);
|
||||
}
|
||||
|
||||
// Устанавливает значение напрямую, БЕЗ проверок и без учёта прошлого значения.
|
||||
// Используется только при загрузке сохранения -- обычная игровая логика должна
|
||||
// использовать AddResource/TrySpend, а не этот метод.
|
||||
@@ -154,7 +127,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
// ---------------- Контракт IEconomy ----------------
|
||||
|
||||
float IEconomy.Get(ResourceType type) => GetResource(type);
|
||||
float IEconomy.GetCapacity(ResourceType type) => GetCapacity(type);
|
||||
bool IEconomy.Has(ResourceType type, float amount) => HasEnough(type, amount);
|
||||
void IEconomy.Add(ResourceType type, float amount) => AddResource(type, amount);
|
||||
|
||||
@@ -188,7 +160,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
class EconomySaveData
|
||||
{
|
||||
public List<ResourceSaveEntry> resources = new List<ResourceSaveEntry>();
|
||||
public List<ResourceSaveEntry> capacities = new List<ResourceSaveEntry>(); // лимиты склада (этап 1)
|
||||
}
|
||||
|
||||
public string SaveKey => "economy";
|
||||
@@ -198,8 +169,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
var data = new EconomySaveData();
|
||||
foreach (var kvp in resources)
|
||||
data.resources.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
|
||||
foreach (var kvp in capacities)
|
||||
data.capacities.Add(new ResourceSaveEntry { type = kvp.Key, amount = kvp.Value });
|
||||
return JsonUtility.ToJson(data);
|
||||
}
|
||||
|
||||
@@ -207,8 +176,6 @@ public class ResourceManager : MonoBehaviour, IEconomy, ISaveParticipant
|
||||
{
|
||||
var data = JsonUtility.FromJson<EconomySaveData>(json);
|
||||
if (data?.resources == null) return;
|
||||
if (data.capacities != null)
|
||||
foreach (var entry in data.capacities) SetCapacity(entry.type, entry.amount);
|
||||
foreach (var entry in data.resources) SetResource(entry.type, entry.amount);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
using UnityEngine;
|
||||
|
||||
// УСТАРЕЛО (этап 3). Раньше здание само добывало ресурс по таймеру (5 единиц раз в 3 секунды ~ 6000 в час).
|
||||
// Теперь добычу дают только рабочие (ProductionService), этот компонент ничего не делает.
|
||||
// Класс оставлен, чтобы на старых префабах не появилось "Missing Script". Новым зданиям не вешать.
|
||||
// Вешается на ЛЮБОЕ здание (лесопилка, каменоломня, шахта) -- настраиваешь
|
||||
// в Inspector, какой ресурс и сколько оно производит. Один скрипт на все типы построек.
|
||||
//
|
||||
// ПРИМЕР НАСТРОЙКИ:
|
||||
// Лесопилка: resourceType = Wood, amountPerTick = 5, tickInterval = 3
|
||||
// Каменоломня: resourceType = Stone, amountPerTick = 4, tickInterval = 3
|
||||
// Шахта: resourceType = Iron, amountPerTick = 2, tickInterval = 4 (руда добывается медленнее)
|
||||
|
||||
public class ResourceProducer : MonoBehaviour
|
||||
{
|
||||
[Header("Что производит это здание")]
|
||||
public ResourceType resourceType;
|
||||
public float amountPerTick = 1f;
|
||||
public float tickInterval = 3f;
|
||||
public float tickInterval = 3f; // секунд между "тиками" производства
|
||||
|
||||
[Header("Уровень здания (для будущего апгрейда)")]
|
||||
public int level = 1;
|
||||
[Tooltip("Множитель производства за каждый уровень выше первого")]
|
||||
public float perLevelMultiplier = 1.2f;
|
||||
|
||||
void Awake()
|
||||
private float timer;
|
||||
|
||||
void Update()
|
||||
{
|
||||
enabled = false;
|
||||
timer += Time.deltaTime;
|
||||
|
||||
if (timer >= tickInterval)
|
||||
{
|
||||
timer -= tickInterval;
|
||||
Produce();
|
||||
}
|
||||
}
|
||||
|
||||
void Produce()
|
||||
{
|
||||
if (ResourceManager.Instance == null)
|
||||
{
|
||||
Debug.LogWarning($"{gameObject.name}: ResourceManager.Instance не найден. " +
|
||||
"Убедись, что на сцене есть объект с компонентом ResourceManager.");
|
||||
return;
|
||||
}
|
||||
|
||||
float finalAmount = amountPerTick * Mathf.Pow(perLevelMultiplier, level - 1);
|
||||
ResourceManager.Instance.AddResource(resourceType, finalAmount);
|
||||
Debug.Log($"{gameObject.name} произвел {finalAmount} {resourceType}. Итого: {ResourceManager.Instance.GetResource(resourceType)}");
|
||||
}
|
||||
|
||||
// Вызывай при апгрейде здания (кнопка "улучшить" повесишь позже)
|
||||
public void Upgrade()
|
||||
{
|
||||
level++;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "Bogatyri.Economy.Rules",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// Правила добычи -- только числа (заполняются из EconomyConfig в App/GameModules).
|
||||
public sealed class ProductionRules
|
||||
{
|
||||
// Норма T1 в час на одного рабочего, по профессиям (порядок как в WorkJob)
|
||||
public double[] PerWorkerPerHour = { 2.0, 2.0, 1.5, 1.0, 0.4 };
|
||||
|
||||
public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30)
|
||||
public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено
|
||||
public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено
|
||||
}
|
||||
|
||||
// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md
|
||||
// Без Unity: правила те же, что будут на сервере.
|
||||
//
|
||||
// Ресурс производят люди, здание даёт только места и эффективность:
|
||||
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий.
|
||||
// Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет.
|
||||
// Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт.
|
||||
//
|
||||
// Produce вызывает модуль Населения в своём расчёте, поэтому в игре, офлайн и при перемотке правила одни.
|
||||
// На склад идут только целые единицы, доли копятся здесь (склад хранит float).
|
||||
public sealed class ProductionService : IProduction, ISaveParticipant
|
||||
{
|
||||
const double Epsilon = 1e-9;
|
||||
|
||||
readonly ProductionRules rules;
|
||||
readonly int[] slots = new int[WorkJobs.Count];
|
||||
readonly double[] efficiency = new double[WorkJobs.Count];
|
||||
readonly double[] pending = new double[WorkJobs.Count]; // добыто, но ещё не отдано на склад (< 1)
|
||||
|
||||
public ProductionService(ProductionRules rules)
|
||||
{
|
||||
this.rules = rules ?? new ProductionRules();
|
||||
ResetState();
|
||||
}
|
||||
|
||||
public ProductionRules Rules => rules;
|
||||
|
||||
// ---------------- Здания города ----------------
|
||||
|
||||
// Здания-рабочие места: (профессия, уровень здания). Вызывает App/CitySync, когда город изменился.
|
||||
public void SetBuildings(IEnumerable<KeyValuePair<WorkJob, int>> buildings)
|
||||
{
|
||||
var weighted = new double[WorkJobs.Count];
|
||||
Array.Clear(slots, 0, slots.Length);
|
||||
foreach (var b in buildings)
|
||||
{
|
||||
int j = (int)b.Key;
|
||||
if (j < 0 || j >= WorkJobs.Count) continue;
|
||||
int level = Math.Max(1, b.Value);
|
||||
int s = BuildingSlots(level);
|
||||
slots[j] += s;
|
||||
weighted[j] += s * BuildingEfficiency(level);
|
||||
}
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
efficiency[j] = slots[j] > 0 ? weighted[j] / slots[j] : 1.0;
|
||||
}
|
||||
|
||||
public int BuildingSlots(int level) => rules.SlotsPerBuilding + rules.SlotsPerLevel * (Math.Max(1, level) - 1);
|
||||
public double BuildingEfficiency(int level) => 1.0 + rules.EfficiencyPerLevel * (Math.Max(1, level) - 1);
|
||||
|
||||
// ---------------- Контракт IProduction ----------------
|
||||
|
||||
public int Slots(WorkJob job) => slots[(int)job];
|
||||
public float Efficiency(WorkJob job) => (float)efficiency[(int)job];
|
||||
|
||||
public float RatePerHour(ResourceType type)
|
||||
{
|
||||
if (!WorkJobs.TryFromResource(type, out WorkJob job)) return 0f;
|
||||
return (float)JobRate(job);
|
||||
}
|
||||
|
||||
double JobRate(WorkJob job)
|
||||
{
|
||||
int j = (int)job;
|
||||
if (!GameServices.TryGet(out IPopulation population)) return 0;
|
||||
int working = Math.Min(population.GetWorkers(job), slots[j]);
|
||||
return working * rules.PerWorkerPerHour[j] * efficiency[j];
|
||||
}
|
||||
|
||||
public void Produce(double hours)
|
||||
{
|
||||
if (hours <= 0 || !GameServices.TryGet(out IEconomy economy)) return;
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
{
|
||||
var job = (WorkJob)j;
|
||||
pending[j] += JobRate(job) * hours;
|
||||
int whole = (int)Math.Floor(pending[j] + Epsilon);
|
||||
if (whole <= 0) continue;
|
||||
pending[j] = Math.Max(0, pending[j] - whole);
|
||||
economy.Add(WorkJobs.Resource(job), whole); // сверх лимита склада Экономика не прибавит
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Сохранение (секция "production") ----------------
|
||||
// slots=<места по профессиям> eff=<эффективность> pending=<доли>
|
||||
|
||||
public string SaveKey => "production";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("slots=").Append(Join(slots)).Append('\n');
|
||||
sb.Append("eff=").Append(Join(efficiency)).Append('\n');
|
||||
sb.Append("pending=").Append(Join(pending)).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Restore(string data)
|
||||
{
|
||||
ResetState();
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
foreach (var raw in data.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.StartsWith("slots=")) ParseInto(line.Substring(6), v => Math.Max(0, (int)v), slots);
|
||||
else if (line.StartsWith("eff=")) ParseInto(line.Substring(4), v => Math.Max(0.1, v), efficiency);
|
||||
else if (line.StartsWith("pending=")) ParseInto(line.Substring(8), v => Math.Max(0, Math.Min(0.999, v)), pending);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
Array.Clear(slots, 0, slots.Length);
|
||||
Array.Clear(pending, 0, pending.Length);
|
||||
for (int j = 0; j < WorkJobs.Count; j++) efficiency[j] = 1.0;
|
||||
}
|
||||
|
||||
static string Join(int[] a)
|
||||
{
|
||||
var parts = new string[a.Length];
|
||||
for (int i = 0; i < a.Length; i++) parts[i] = a[i].ToString(CultureInfo.InvariantCulture);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
static string Join(double[] a)
|
||||
{
|
||||
var parts = new string[a.Length];
|
||||
for (int i = 0; i < a.Length; i++) parts[i] = a[i].ToString("R", CultureInfo.InvariantCulture);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
static void ParseInto(string csv, Func<double, int> conv, int[] target)
|
||||
{
|
||||
string[] parts = csv.Split(',');
|
||||
for (int i = 0; i < target.Length && i < parts.Length; i++)
|
||||
if (double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double v)) target[i] = conv(v);
|
||||
}
|
||||
|
||||
static void ParseInto(string csv, Func<double, double> conv, double[] target)
|
||||
{
|
||||
string[] parts = csv.Split(',');
|
||||
for (int i = 0; i < target.Length && i < parts.Length; i++)
|
||||
if (double.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out double v)) target[i] = conv(v);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 040af6a37da54abcad0b6ebf2037adb0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1df99e6793644da19a8df1e9da262cd6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "Bogatyri.Population",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
|
||||
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
|
||||
public sealed class PopulationRules
|
||||
{
|
||||
// Стартовый город T1: 300 человек = 120 рабочих + 30 воинов + 150 остальных.
|
||||
// Доля способных работать (120 из 270 жителей) сохраняется и при росте города.
|
||||
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;
|
||||
|
||||
// Предел населения (жильё). Решение команды от 19.10.2026: 300 жителей при пределе 300 -- прирост стоит,
|
||||
// построен Дом -- прирост идёт. Прибавки за Дом и Ратушу НЕ утверждены командой.
|
||||
public int HousingBase = 300;
|
||||
public int HousingPerTownHallLevel = 50;
|
||||
public int HousingPerHouseLevel = 50; // каждый уровень Дома (здание "Население")
|
||||
|
||||
// Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено.
|
||||
public int MaxOfflineHours = 48;
|
||||
|
||||
public int StartCivilians => StartTotal - StartSoldiers;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30bbbd3ca6154dda923f86163c3b90a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,405 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают.
|
||||
// Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере.
|
||||
//
|
||||
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
|
||||
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
|
||||
// Жители: часть может работать (Workforce, стартовый город -- 120 из 270), из них рабочие стоят на профессиях.
|
||||
//
|
||||
// Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре,
|
||||
// при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же.
|
||||
// В каждом куске: сначала добыча (IProduction), потом еда, потом прирост.
|
||||
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;
|
||||
readonly int[] jobs = new int[WorkJobs.Count]; // рабочих по профессиям
|
||||
bool jobsInitialized; // рабочих хоть раз расставляли (старому сохранению расставим сами)
|
||||
int houseLevels; // сумма уровней Домов в городе (сообщает App/CitySync)
|
||||
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; }
|
||||
public bool JobsInitialized => jobsInitialized;
|
||||
public int HouseLevels => houseLevels;
|
||||
|
||||
// ---------------- Контракт IPopulation ----------------
|
||||
|
||||
public int Civilians => civilians;
|
||||
|
||||
public int Workers
|
||||
{
|
||||
get { int n = 0; foreach (var j in jobs) n += j; return n; }
|
||||
}
|
||||
|
||||
public int Idle => Math.Max(0, civilians - Workers);
|
||||
|
||||
// Доля жителей, способных работать: 120 из 270 в стартовом городе
|
||||
public int Workforce
|
||||
{
|
||||
get
|
||||
{
|
||||
int startCiv = Math.Max(1, rules.StartCivilians);
|
||||
double share = Math.Min(1.0, rules.StartWorkers / (double)startCiv);
|
||||
return (int)Math.Floor(civilians * share + Epsilon);
|
||||
}
|
||||
}
|
||||
|
||||
public int FreeWorkers => Math.Max(0, Workforce - 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) + rules.HousingPerHouseLevel * houseLevels;
|
||||
}
|
||||
}
|
||||
|
||||
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 int GetWorkers(WorkJob job)
|
||||
{
|
||||
int j = (int)job;
|
||||
return j >= 0 && j < WorkJobs.Count ? jobs[j] : 0;
|
||||
}
|
||||
|
||||
public int SetWorkers(WorkJob job, int count)
|
||||
{
|
||||
int j = (int)job;
|
||||
if (j < 0 || j >= WorkJobs.Count) return 0;
|
||||
|
||||
int limit = jobs[j] + FreeWorkers; // больше, чем есть свободных, не поставить
|
||||
int slots = SlotsFor(job);
|
||||
if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях
|
||||
int value = Math.Max(0, Math.Min(count, limit));
|
||||
if (value != jobs[j])
|
||||
{
|
||||
jobs[j] = value;
|
||||
jobsInitialized = true;
|
||||
WorkersChanged();
|
||||
}
|
||||
return jobs[j];
|
||||
}
|
||||
|
||||
// Мест для профессии: -1 -- модуль добычи не подключён (тесты без него), ограничения нет
|
||||
static int SlotsFor(WorkJob job)
|
||||
{
|
||||
return GameServices.TryGet(out IProduction production) ? production.Slots(job) : -1;
|
||||
}
|
||||
|
||||
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; // без склада считать нечего
|
||||
GameServices.TryGet(out IProduction production);
|
||||
|
||||
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;
|
||||
|
||||
// 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест
|
||||
production?.Produce(hours);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// ---------------- Рабочие: расстановка ----------------
|
||||
|
||||
// Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено.
|
||||
public int AutoFill(WorkJob job)
|
||||
{
|
||||
int slots = SlotsFor(job);
|
||||
int room = slots < 0 ? FreeWorkers : Math.Min(FreeWorkers, slots - jobs[(int)job]);
|
||||
if (room <= 0) return 0;
|
||||
SetWorkers(job, jobs[(int)job] + room);
|
||||
return room;
|
||||
}
|
||||
|
||||
// Расставить свободных по всем профессиям поровну (старое сохранение, первый запуск после этапа 3)
|
||||
public void AutoFillAll()
|
||||
{
|
||||
bool changed = false;
|
||||
bool progress = true;
|
||||
while (FreeWorkers > 0 && progress)
|
||||
{
|
||||
progress = false;
|
||||
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0; j++)
|
||||
{
|
||||
int slots = SlotsFor((WorkJob)j);
|
||||
if (slots >= 0 && jobs[j] >= slots) continue;
|
||||
jobs[j]++;
|
||||
progress = changed = true;
|
||||
}
|
||||
}
|
||||
jobsInitialized = true;
|
||||
if (changed) WorkersChanged();
|
||||
}
|
||||
|
||||
// Мест стало меньше или работников меньше (призыв в будущем) -- снять лишних
|
||||
public void ClampJobs()
|
||||
{
|
||||
bool changed = false;
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
{
|
||||
int slots = SlotsFor((WorkJob)j);
|
||||
if (slots >= 0 && jobs[j] > slots) { jobs[j] = slots; changed = true; }
|
||||
}
|
||||
int over = Workers - Workforce;
|
||||
for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--)
|
||||
{
|
||||
int take = Math.Min(over, jobs[j]);
|
||||
jobs[j] -= take; over -= take;
|
||||
if (take > 0) changed = true;
|
||||
}
|
||||
if (changed) WorkersChanged();
|
||||
}
|
||||
|
||||
// Сумма уровней Домов (каждый уровень прибавляет к пределу жилья)
|
||||
public void SetHouseLevels(int levels)
|
||||
{
|
||||
levels = Math.Max(0, levels);
|
||||
if (levels == houseLevels) return;
|
||||
houseLevels = levels;
|
||||
Changed();
|
||||
}
|
||||
|
||||
void WorkersChanged()
|
||||
{
|
||||
EventBus.Publish(new WorkersChangedEvent { Workers = Workers, FreeWorkers = FreeWorkers });
|
||||
}
|
||||
|
||||
// ---------------- Связь с остальной игрой (вызывает 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);
|
||||
Array.Clear(jobs, 0, jobs.Length);
|
||||
jobsInitialized = false;
|
||||
houseLevels = 0;
|
||||
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');
|
||||
var parts = new string[jobs.Length];
|
||||
for (int i = 0; i < jobs.Length; i++) parts[i] = jobs[i].ToString(CultureInfo.InvariantCulture);
|
||||
sb.Append("jobs=").Append(string.Join(",", parts)).Append('\n');
|
||||
sb.Append("jobsInit=").Append(jobsInitialized ? "1" : "0").Append('\n');
|
||||
sb.Append("houses=").Append(houseLevels.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 "jobs":
|
||||
string[] p = value.Split(',');
|
||||
for (int i = 0; i < jobs.Length && i < p.Length; i++) jobs[i] = Math.Max(0, ParseInt(p[i].Trim(), 0));
|
||||
break;
|
||||
case "jobsInit": jobsInitialized = value == "1"; break;
|
||||
case "houses": houseLevels = Math.Max(0, ParseInt(value, 0)); 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();
|
||||
WorkersChanged();
|
||||
}
|
||||
|
||||
static int ParseInt(string s, int fallback)
|
||||
{
|
||||
return int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46182f0762894c3aab9763821d2c9c19
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -16,7 +16,6 @@ public class GameBootstrap : MonoBehaviour
|
||||
else
|
||||
{
|
||||
Debug.Log("[СТАРТ] Сохранения нет, начинаем новую игру.");
|
||||
EventBus.Publish(new NewGameStartedEvent()); // стартовые ресурсы и войско -- в App/GameModules
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
"UnityEditor.TestRunner",
|
||||
"Bogatyri.Core",
|
||||
"Bogatyri.Army",
|
||||
"Bogatyri.Population",
|
||||
"Bogatyri.Economy.Rules",
|
||||
"Bogatyri.Battle",
|
||||
"Bogatyri.World.Simulation",
|
||||
"Bogatyri.Chat",
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
// Предел жилья с запасом -- для тестов прироста (по умолчанию 300: стартовый город упирается в предел)
|
||||
static PopulationRules Roomy() => new PopulationRules { HousingBase = 400 };
|
||||
|
||||
[Test]
|
||||
public void StartCity_AtDefaultHousingCap_DoesNotGrow_HouseLetsItGrow()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
var p = StartCity(); // решение команды от 19.10: 300 жителей при пределе 300 -- прирост стоит
|
||||
p.Tick(T0 + 10 * Hour);
|
||||
Assert.AreEqual(300, p.Total);
|
||||
|
||||
p.SetHouseLevels(1); // построен Дом -- +50 мест
|
||||
p.Tick(T0 + 20 * Hour);
|
||||
Assert.AreEqual(325, p.Total, "построен Дом -- прирост идёт");
|
||||
}
|
||||
|
||||
// Стартовый город: 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.Workforce, "могут работать 120");
|
||||
Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)");
|
||||
Assert.AreEqual(120, p.FreeWorkers);
|
||||
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(Roomy());
|
||||
|
||||
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(Roomy());
|
||||
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(Roomy());
|
||||
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(Roomy());
|
||||
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(Roomy());
|
||||
p.SetWorkers(WorkJob.Farmer, 40);
|
||||
p.SetHouseLevels(2);
|
||||
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(40, q.GetWorkers(WorkJob.Farmer));
|
||||
Assert.AreEqual(2, q.HouseLevels);
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------- Заглушки модулей ----------------
|
||||
|
||||
public class FakeEconomy : IEconomy
|
||||
{
|
||||
readonly Dictionary<ResourceType, float> res = new Dictionary<ResourceType, float>();
|
||||
readonly Dictionary<ResourceType, float> caps = new Dictionary<ResourceType, float>();
|
||||
public void Set(ResourceType t, float v) { res[t] = v; }
|
||||
public void SetCapacity(ResourceType t, float v) { caps[t] = v; }
|
||||
public float GetCapacity(ResourceType type) => caps.TryGetValue(type, out float c) ? c : float.PositiveInfinity;
|
||||
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 cur = Get(type), v = cur + amount;
|
||||
if (amount > 0) v = System.Math.Min(v, System.Math.Max(cur, GetCapacity(type))); // как ResourceManager
|
||||
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; }
|
||||
}
|
||||
|
||||
public class FakeSettlement : ISettlement
|
||||
{
|
||||
public int TownHallLevel { get; set; }
|
||||
public int BuildingCount => 0;
|
||||
public bool IsCityLoaded => true;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6570cf436af4ee1bc60a25c8714b844
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,196 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
|
||||
// Тесты добычи через рабочих (этап 3) и лимитов склада (этап 1).
|
||||
// Запуск: Window -> General -> Test Runner -> EditMode -> Run All.
|
||||
// Проверка из дизайн-документа: сбалансированный город даёт 90 еды, 50 дерева, 22.5 камня и 15 металла в час.
|
||||
public class ProductionTests
|
||||
{
|
||||
const long T0 = 1_800_000_000;
|
||||
const long Hour = 3600;
|
||||
|
||||
PopulationTests.FakeEconomy economy;
|
||||
ArmyService army;
|
||||
PopulationService population;
|
||||
ProductionService production;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
GameServices.Clear();
|
||||
EventBus.Clear();
|
||||
SaveRegistry.Clear();
|
||||
GameClock.ResetOffset();
|
||||
|
||||
economy = new PopulationTests.FakeEconomy();
|
||||
army = new ArmyService();
|
||||
population = new PopulationService(new PopulationRules());
|
||||
production = new ProductionService(new ProductionRules());
|
||||
GameServices.Register<IEconomy>(economy);
|
||||
GameServices.Register<ISettlement>(new PopulationTests.FakeSettlement { TownHallLevel = 1 });
|
||||
GameServices.Register<IArmy>(army);
|
||||
GameServices.Register<IPopulation>(population);
|
||||
GameServices.Register<IProduction>(production);
|
||||
|
||||
army.AddTroops(0, 30);
|
||||
population.Tick(T0);
|
||||
}
|
||||
|
||||
// По одному зданию 1 уровня на каждую профессию, кроме золота (нужно 2 фермы для 45 фермеров)
|
||||
static List<KeyValuePair<WorkJob, int>> Buildings(params (WorkJob job, int level)[] list)
|
||||
{
|
||||
var result = new List<KeyValuePair<WorkJob, int>>();
|
||||
foreach (var b in list) result.Add(new KeyValuePair<WorkJob, int>(b.job, b.level));
|
||||
return result;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BalancedCity_Gives90Food_50Wood_22_5Stone_15Iron()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1),
|
||||
(WorkJob.Stonecutter, 1), (WorkJob.MinerIron, 1)));
|
||||
population.SetWorkers(WorkJob.Farmer, 45);
|
||||
population.SetWorkers(WorkJob.Woodcutter, 25);
|
||||
population.SetWorkers(WorkJob.Stonecutter, 15);
|
||||
population.SetWorkers(WorkJob.MinerIron, 15);
|
||||
|
||||
Assert.AreEqual(90f, production.RatePerHour(ResourceType.Food), 0.001f);
|
||||
Assert.AreEqual(50f, production.RatePerHour(ResourceType.Wood), 0.001f);
|
||||
Assert.AreEqual(22.5f, production.RatePerHour(ResourceType.Stone), 0.001f);
|
||||
Assert.AreEqual(15f, production.RatePerHour(ResourceType.Iron), 0.001f);
|
||||
Assert.AreEqual(100, population.Workers);
|
||||
Assert.AreEqual(20, population.FreeWorkers);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OneHour_AddsProduction_AndFoodIsNetOfEating()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100);
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Farmer, 45);
|
||||
population.SetWorkers(WorkJob.Woodcutter, 25);
|
||||
|
||||
population.Tick(T0 + Hour);
|
||||
Assert.AreEqual(100 + 90 - 33, economy.Get(ResourceType.Food), 0.01, "+90 добыли, -33 съели");
|
||||
Assert.AreEqual(50, economy.Get(ResourceType.Wood), 0.01);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProductionComesBeforeEating_NoFalseStarvationOffline()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 0); // склад пуст, но фермы кормят город
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
||||
population.SetWorkers(WorkJob.Farmer, 45);
|
||||
|
||||
population.Tick(T0 + 10 * Hour);
|
||||
Assert.IsFalse(population.IsStarving, "сначала добыли, потом съели");
|
||||
Assert.AreEqual((90 - 33) * 10, economy.Get(ResourceType.Food), 1.0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Workers_LimitedBySlots_AndByFreeWorkers()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест
|
||||
Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить");
|
||||
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Farmer, 1),
|
||||
(WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
||||
Assert.AreEqual(90, population.SetWorkers(WorkJob.Farmer, 120), "свободных осталось 90 из 120");
|
||||
Assert.AreEqual(0, population.FreeWorkers);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoBuilding_NoWorkers()
|
||||
{
|
||||
Assert.AreEqual(0, population.SetWorkers(WorkJob.MinerGold, 10), "нет Золотого рудника -- нет мест");
|
||||
Assert.AreEqual(0f, production.RatePerHour(ResourceType.Gold));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HigherLevel_MoreSlots_AndEfficiency()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 3))); // 30 + 2×10 мест, норма ×1.2
|
||||
Assert.AreEqual(50, production.Slots(WorkJob.Woodcutter));
|
||||
Assert.AreEqual(1.2f, production.Efficiency(WorkJob.Woodcutter), 0.0001f);
|
||||
|
||||
population.SetWorkers(WorkJob.Woodcutter, 50);
|
||||
Assert.AreEqual(50 * 2 * 1.2f, production.RatePerHour(ResourceType.Wood), 0.001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StorageCap_OverflowIsLost()
|
||||
{
|
||||
economy.SetCapacity(ResourceType.Wood, 1000);
|
||||
economy.Set(ResourceType.Wood, 990);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30); // 60 в час
|
||||
|
||||
population.Tick(T0 + Hour);
|
||||
Assert.AreEqual(1000, economy.Get(ResourceType.Wood), 0.0001, "склад полон -- лишнее пропало");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutoFillAll_SpreadsWorkersEvenly()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Stonecutter, 1)));
|
||||
population.AutoFillAll();
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter));
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter));
|
||||
Assert.AreEqual(30, population.FreeWorkers);
|
||||
Assert.IsTrue(population.JobsInitialized);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutoFill_NewBuilding_TakesFreeWorkers()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
|
||||
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer));
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
||||
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer), "вторая ферма -- ещё 30");
|
||||
Assert.AreEqual(60, population.GetWorkers(WorkJob.Farmer));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FewerSlots_ClampJobs_RemovesExtraWorkers()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
||||
population.SetWorkers(WorkJob.Farmer, 60);
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
|
||||
population.ClampJobs();
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||
Assert.AreEqual(90, population.FreeWorkers);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ManySmallTicks_EqualOneBigTick_WithProduction()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 500);
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Farmer, 30);
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
for (long t = T0 + 2; t <= T0 + 5 * Hour; t += 2) population.Tick(t);
|
||||
float foodA = economy.Get(ResourceType.Food), woodA = economy.Get(ResourceType.Wood);
|
||||
|
||||
SetUp();
|
||||
economy.Set(ResourceType.Food, 500);
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Farmer, 30);
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
population.Tick(T0 + 5 * Hour);
|
||||
|
||||
Assert.AreEqual(foodA, economy.Get(ResourceType.Food), 1.0);
|
||||
Assert.AreEqual(woodA, economy.Get(ResourceType.Wood), 1.0);
|
||||
Assert.AreEqual(300, woodA, 1.0, "30 лесорубов × 2 × 5 часов");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveAndRestore_KeepsSlots()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Stonecutter, 2)));
|
||||
var copy = new ProductionService(new ProductionRules());
|
||||
copy.Restore(production.Capture());
|
||||
Assert.AreEqual(40, copy.Slots(WorkJob.Stonecutter));
|
||||
Assert.AreEqual(1.1f, copy.Efficiency(WorkJob.Stonecutter), 0.0001f);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46ff65caf6ef4e93aaf2db576d6b6997
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+3
-10
@@ -15,23 +15,19 @@
|
||||
| `Core/ResourceType.cs` | Перечисление ресурсов `ResourceType` и структура `ResourceAmount` (тип + количество). Числа enum не менять: они в сохранениях. |
|
||||
| `Core/LocalPlayer.cs` | Id и имя локального игрока (пока без аккаунтов). |
|
||||
| `Core/GameServices.cs` | Реестр модулей: `Register<T>`, `Get<T>`, `TryGet<T>`, `Has<T>`, `Unregister<T>`. |
|
||||
| `Core/WorkJob.cs` | Профессии рабочих `WorkJob` и `WorkJobs` (какой ресурс добывает профессия, русское название). Числа enum не менять: они в сохранениях. |
|
||||
| `Core/GameClock.cs` | Часы игры: `UnixNow` = реальное время + сдвиг. `Advance(секунд)` — перемотка для отладки, публикует `GameTimeAdvancedEvent`. Сдвиг не сохраняется. Модули берут время только отсюда. |
|
||||
| `Core/EventBus.cs` | Шина событий: `Subscribe<T>`, `Unsubscribe<T>`, `Publish<T>`. Ошибка одного подписчика не мешает остальным (уходит в `OnHandlerError`). |
|
||||
| `Core/Contracts/ModuleContracts.cs` | Контракты всех модулей и `SaveRegistry` — см. таблицу ниже. |
|
||||
| `Core/Events/GameEvents.cs` | Все события модулей. |
|
||||
| `Core.Unity/CoreUnityBootstrap.cs` | При старте игры очищает реестр, шину и список сохраняемых, сбрасывает перемотку `GameClock`, подключает ошибки шины к `Debug.LogException`. |
|
||||
| `Core.Unity/CoreUnityBootstrap.cs` | При старте игры очищает реестр, шину и список сохраняемых, подключает ошибки шины к `Debug.LogException`. |
|
||||
| `Core.Unity/UIBlocker.cs` | «Палец над интерфейсом?» — общая проверка для кликов по миру. |
|
||||
|
||||
## Контракты
|
||||
|
||||
| Контракт | Реализует | Что умеет |
|
||||
| --- | --- | --- |
|
||||
| `IEconomy` | Экономика (`ResourceManager`) | Узнать, добавить, потратить ресурс; проверить и списать цену целиком; лимит склада. |
|
||||
| `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. |
|
||||
| `IEconomy` | Экономика (`ResourceManager`) | Узнать, добавить, потратить ресурс; проверить и списать цену целиком. |
|
||||
| `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. |
|
||||
| `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. |
|
||||
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. |
|
||||
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
|
||||
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
|
||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. |
|
||||
@@ -47,8 +43,7 @@
|
||||
3. `BeforeSceneLoad` — создаётся `MarchManager` (Мир).
|
||||
4. `Awake` объектов сцены — `ResourceManager`, `SaveManager` регистрируются сами.
|
||||
5. `AfterSceneLoad` — перенос старых походов из `progress.json`.
|
||||
6. `Start` — `GameBootstrap` загружает сохранение или, если файла нет, публикует `NewGameStartedEvent`.
|
||||
7. Раз в 2 секунды — `App/PopulationTicker` досчитывает Население.
|
||||
6. `Start` — `GameBootstrap` загружает сохранение.
|
||||
|
||||
## Решения и план
|
||||
|
||||
@@ -58,8 +53,6 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | `WorkJob`; контракт `IProduction`; `IEconomy.GetCapacity`; `IPopulation`: `Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`; событие `WorkersChangedEvent`. |
|
||||
| 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. |
|
||||
| 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. |
|
||||
| 19.10.2026 | Claude | Контракт `ICircle` и типы `CircleInfo`, `CircleMember`, `CircleRole`; события `CircleJoinedEvent`, `CircleLeftEvent`; `IChat.CanWrite`. |
|
||||
| 19.10.2026 | Claude | Контракт `IChat`, типы `ChatChannel` и `ChatMessage`, событие `ChatMessageReceivedEvent`, `LocalPlayer`. |
|
||||
|
||||
+9
-66
@@ -2,9 +2,7 @@
|
||||
|
||||
Ресурсы и производство: сколько у игрока дерева, еды, золота и остального, кто их добывает и тратит.
|
||||
|
||||
- **Сборки:**
|
||||
- `Bogatyri.Economy` — склад ресурсов и здания-рабочие места (Unity);
|
||||
- `Bogatyri.Economy.Rules` — правила добычи, чистый C# без Unity (папка `Rules`).
|
||||
- **Сборка:** `Bogatyri.Economy`.
|
||||
- **Папка:** `Assets/Scripts/Modules/Economy`.
|
||||
- **Зависит от:** ядра.
|
||||
|
||||
@@ -17,41 +15,9 @@
|
||||
| `Add(type, amount)` | Добавить (или отнять отрицательным числом; в минус не уходит). |
|
||||
| `TrySpend(type, amount)` | Потратить, если хватает. |
|
||||
| `CanAfford(cost)` / `TrySpend(cost)` | Проверить и списать цену из нескольких ресурсов — всё или ничего. |
|
||||
| `GetCapacity(type)` | Лимит склада; `float.PositiveInfinity` — без лимита (люди, сила). |
|
||||
|
||||
Получить: `GameServices.Get<IEconomy>()`.
|
||||
|
||||
## Контракт `IProduction` (добыча через рабочих, этап 3)
|
||||
|
||||
| Метод | Что делает |
|
||||
| --- | --- |
|
||||
| `Slots(job)` | Мест для рабочих этой профессии во всех зданиях города. |
|
||||
| `Efficiency(job)` | Множитель нормы от уровня зданий (1 — здания 1 уровня). |
|
||||
| `RatePerHour(type)` | Добыча ресурса в час сейчас. |
|
||||
| `Produce(hours)` | Добыть за `hours` часов. Вызывает модуль Населения в своём расчёте: сначала добыли, потом съели. |
|
||||
|
||||
Получить: `GameServices.Get<IProduction>()`.
|
||||
|
||||
## Ресурсы и склады (этап 1)
|
||||
|
||||
- **Ресурсы:** дерево, еда, золото, камень, металл, синий кристалл. `Power` («Сила») — не ресурс, а вычисляемая мощь для рейтинга (`GameProgress.TotalPower`), лимита у неё нет. «Люди» — отражение модуля Населения.
|
||||
- **Лимиты:** без построек — 1000 дерева, камня, металла и золота, 1500 еды. Каждый уровень **Склада** — +1000 к первым четырём, каждый уровень **Амбара** — +1500 к еде. Синий кристалл — отдельный небольшой лимит 50 (решение команды от 19.10). Все числа — `EconomyConfig`, раздел «Склады и лимиты», не утверждены.
|
||||
- **Сверх лимита ресурс не прибавляется** — ни добыча, ни награды, лишнее пропадает. Если запас уже выше лимита (старое сохранение), он не урезается, просто больше не растёт.
|
||||
- **Склад и Амбар** появляются в списке стройки кодом (`App/StorageBootstrap`, как Казармы), пока без своего арта — отличаются цветом. Компонент `App/StorageBuilding`.
|
||||
- **Верхняя панель** (`ResourceUIText`): «запас / лимит» и строкой ниже прирост в час (у еды — добыча минус расход). Без сокращений 1.2K. У «Людей» — население / предел жилья и прирост людей.
|
||||
|
||||
## Добыча через рабочих (этап 3)
|
||||
|
||||
- **Ресурс производят люди, здание даёт места и эффективность.** Добыча в час = min(рабочие, места) × норма × эффективность.
|
||||
- **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник).
|
||||
- **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено.
|
||||
- **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен.
|
||||
- **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья.
|
||||
- **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6).
|
||||
- **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн.
|
||||
- **Новые места заполняются сами:** построили или улучшили здание — свободные рабочие встают туда. Старое сохранение при первом запуске — рабочие расставляются поровну по всем местам.
|
||||
- **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час.
|
||||
|
||||
## События
|
||||
|
||||
- **Публикует:** `ResourceChangedEvent` — при любом изменении ресурса (тип и новое значение).
|
||||
@@ -61,9 +27,7 @@
|
||||
|
||||
## Сохранение
|
||||
|
||||
Секция `"economy"`: `{ "resources": [ { "type", "amount" } ], "capacities": [ … ] }` — имена полей как в старом `save.json`, лимиты склада — новым списком.
|
||||
|
||||
Секция `"production"` (`ProductionService`, простые строки): `slots=` — места по профессиям, `eff=` — эффективность, `pending=` — доли добычи меньше 1.
|
||||
Секция `"economy"`: `{ "resources": [ { "type", "amount" } ] }` — имена полей как в старом `save.json`.
|
||||
|
||||
Сброс (`ResetState`): стартовые значения из Inspector `ResourceManager` (`startWood` и т.д.).
|
||||
|
||||
@@ -71,43 +35,22 @@
|
||||
|
||||
| Файл | Что это |
|
||||
| --- | --- |
|
||||
| `ResourceManager.cs` | Хранилище ресурсов и лимитов склада, реализует `IEconomy` и `ISaveParticipant`. Живёт между сценами. |
|
||||
| `ResourceCollector.cs` | Здание-рабочее место (или Дом, Кристалл). Реализует `ILeveledBuilding`. Тап — событие `Tapped`. |
|
||||
| `ResourceProducer.cs` | Устарело: выключен, оставлен для старых префабов. |
|
||||
| `Rules/ProductionService.cs` | Добыча через рабочих: `ProductionRules`, `IProduction`, секция `"production"`. |
|
||||
| `ResourceManager.cs` | Хранилище ресурсов, реализует `IEconomy` и `ISaveParticipant`. Живёт между сценами. |
|
||||
| `ResourceProducer.cs` | Здание, добывающее ресурс по таймеру. |
|
||||
| `ResourceCollector.cs` | Временная добыча по клику. Реализует `ILeveledBuilding`. |
|
||||
|
||||
Слой приложения:
|
||||
|
||||
| Файл | Что это |
|
||||
| --- | --- |
|
||||
| `Economy/ResourceUIText.cs` | Ресурс на верхней панели: «запас / лимит» и прирост в час. |
|
||||
| `Economy/BuildingUI.cs` | Полоска над зданием: рабочие / места. |
|
||||
| `App/CitySync.cs` | Здания города → места, Дома, лимиты. |
|
||||
| `App/BuildingWindow.cs` | Окно здания и рабочих. |
|
||||
| `App/StorageBuilding.cs`, `App/StorageBootstrap.cs` | Склад и Амбар. |
|
||||
|
||||
## Таблица баланса `EconomyConfig`
|
||||
|
||||
Все числа экономики — в ассете `Assets/Resources/EconomyConfig.asset` (класс `App/EconomyConfig.cs`, слой приложения). Меняются в Inspector без перекомпиляции. Разделы: население, стартовые ресурсы новой игры, производство T1, месторождения, цена солдата. Числа с пометкой «не утверждено» — временные.
|
||||
|
||||
Модули конфиг не видят: `App/GameModules` переводит его в правила модуля (например, `PopulationRules`). Нет ассета — берутся числа по умолчанию из `EconomyConfig.cs` и в консоли предупреждение.
|
||||
|
||||
Стартовые ресурсы новой игры выдаёт `App/GameModules` (при первой игре и после сброса): ресурс поднимается до стартового, если его меньше.
|
||||
|
||||
Ресурс `Population` («Люди») теперь отражает модуль Населения: `App/GameModules` держит его равным `IPopulation.Total`. Тратить его напрямую не нужно.
|
||||
Интерфейс экономики остаётся в слое приложения: `Assets/Scripts/Economy/ResourceUIText.cs`, `BuildingUI.cs`.
|
||||
|
||||
## Решения и план
|
||||
|
||||
- **Этап 1 (план разработки):** сделано 27.10.2026 — лимиты Склада и Амбара, «запас / лимит» и прирост на панели, без сокращений. `Gems` по-прежнему называется `Gems` (числа enum записаны в сохранениях), в интерфейсе — «Кристаллы».
|
||||
- **Этап 1 (план разработки):** лимиты Склада и Амбара; `Gems` → `BlueCrystal`; `Power` убрать из ресурсов.
|
||||
- **Решение команды от 19.10.2026:** у синего кристалла отдельный небольшой настраиваемый лимит хранения, не бесконечный. Переполнение — одно правило для наград и добычи. Число не утверждено.
|
||||
- **Этап 3:** сделано 27.10.2026 — кликер убран, производство = рабочие × норма × эффективность уровня. Множитель качества месторождения добавится в этапе 4.
|
||||
- Все числа — из `EconomyConfig`, не в коде. Таблица создана 26.10.2026; производство, месторождения и цена солдата в ней уже есть, но используются начиная с этапов 3–5.
|
||||
- **Этап 3:** убрать кликер (`ResourceCollector`); производство = рабочие × норма × качество месторождения × бонус уровня.
|
||||
- Все числа — из `EconomyConfig`, не в коде.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Этапы 1 и 3: лимиты склада (`GetCapacity`, Склад, Амбар), «запас / лимит» и прирост на панели; добыча через рабочих (`Bogatyri.Economy.Rules`, `IProduction`), кликер убран, окно здания и рабочих, улучшение зданий. Исправлены лишние `ResourceUIText` «Люди» на ячейках панели. |
|
||||
| 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. |
|
||||
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. |
|
||||
| 19.10.2026 | Claude | Модуль выделен: `ResourceManager`, `ResourceProducer`, `ResourceCollector` перенесены в `Modules/Economy`. `ResourceManager` реализует `IEconomy`, публикует `ResourceChangedEvent`, сам сохраняет секцию `"economy"`. |
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# Население
|
||||
|
||||
Сколько в городе людей, сколько они едят и как прирастают. Этап 2 плана разработки.
|
||||
|
||||
- **Сборка:** `Bogatyri.Population` (без Unity).
|
||||
- **Папка:** `Assets/Scripts/Modules/Population`.
|
||||
- **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`.
|
||||
|
||||
## Как устроено
|
||||
|
||||
- **Люди города = жители + воины.** Жителей хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
|
||||
- **Жители:** способные работать (`Workforce` — 120 из 270 в стартовом городе, доля сохраняется при росте) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`).
|
||||
- **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить.
|
||||
- **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час.
|
||||
- **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья.
|
||||
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Что ещё происходит при голоде, команда пока не решила.
|
||||
- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены.
|
||||
- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали.
|
||||
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
|
||||
- **Еда списывается целыми единицами,** доли копятся в модуле: склад хранит `float`, и частые мелкие списания давали бы ошибку округления.
|
||||
|
||||
## Контракт `IPopulation`
|
||||
|
||||
| Член | Что это |
|
||||
| --- | --- |
|
||||
| `Total` | Все люди: жители + воины (в городе и в походах). |
|
||||
| `Civilians`, `Workers`, `Idle` | Жители, из них на работе и остальные. |
|
||||
| `Workforce`, `FreeWorkers` | Сколько могут работать; сколько из них без работы. |
|
||||
| `GetWorkers(job)`, `SetWorkers(job, n)` | Рабочие профессии; поставить n (в пределах мест и свободных). |
|
||||
| `Soldiers` | Воины — из модуля Армии. |
|
||||
| `HousingCap` | Предел населения. |
|
||||
| `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. |
|
||||
| `IsStarving` | Еды не хватило на последний расчёт. |
|
||||
| `Tick(unixNow)` | Досчитать население до момента `unixNow`. |
|
||||
|
||||
Получить: `GameServices.Get<IPopulation>()`.
|
||||
|
||||
## События
|
||||
|
||||
- **Публикует:**
|
||||
- `PopulationChangedEvent` — изменилось число людей;
|
||||
- `WorkersChangedEvent` — переставили рабочих;
|
||||
- `StarvationChangedEvent` — голод начался или закончился.
|
||||
- **Слушает** (через `App/GameModules`):
|
||||
- `GameLoadedEvent` — офлайн-прогресс;
|
||||
- `GameTimeAdvancedEvent` — перемотка в отладочной панели;
|
||||
- `TroopsChangedEvent`, `TownHallLevelChangedEvent` — пересчёт общего числа и предела.
|
||||
|
||||
## Сохранение
|
||||
|
||||
Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `kit=`.
|
||||
|
||||
Сброс (`ResetState`): стартовый город, 270 жителей. Стартовое войско (30 пехоты) и стартовые ресурсы выдаёт `App/GameModules` после сброса и при первой игре (`NewGameStartedEvent`) — один раз (`kit=1`). Старым сохранениям без секции населения стартовый набор не выдаётся.
|
||||
|
||||
## Связь с игрой (слой приложения)
|
||||
|
||||
| Файл | Что делает |
|
||||
| --- | --- |
|
||||
| `App/GameModules.cs` | Создаёт модуль с числами из `EconomyConfig`, подписывает на события, выдаёт стартовый набор, держит ресурс «Люди» на верхней панели равным `Total`. |
|
||||
| `App/PopulationTicker.cs` | Раз в 2 секунды вызывает `Tick` (модуль без Unity и не знает про `Update`). |
|
||||
| `App/DebugPanel.cs` | Кнопка «DEV»: «+1 час», «+10 часов», ресурсы, «Еда = 0». Только редактор и Development Build. |
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что это |
|
||||
| --- | --- |
|
||||
| `PopulationRules.cs` | Числа правил (заполняются из `EconomyConfig`). |
|
||||
| `PopulationService.cs` | Модуль: расчёт, контракт `IPopulation`, сохранение. |
|
||||
|
||||
## Тесты
|
||||
|
||||
`Modules/Tests/PopulationTests.cs`:
|
||||
- стартовый город — 300 человек, ест 33 еды в час;
|
||||
- прирост 2.5 в час;
|
||||
- голод останавливает прирост, еда вернулась — прирост идёт;
|
||||
- упёрлись в предел — прирост стоит, Ратуша выше — идёт; предел не превышается;
|
||||
- офлайн ограничен 48 часами;
|
||||
- частые расчёты дают то же, что один большой;
|
||||
- часы назад — ничего не ломается;
|
||||
- перемотка идёт через те же правила;
|
||||
- сохранение;
|
||||
- стартовый набор — один раз;
|
||||
- сброс.
|
||||
|
||||
## Решения и план
|
||||
|
||||
- **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`.
|
||||
- **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»).
|
||||
- **Этап 3:** сделано 27.10.2026 — рабочие по профессиям, Дома, добыча в общем расчёте.
|
||||
- **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026).
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Рабочие по профессиям (`Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`, автозаполнение), Дома в пределе жилья, базовый предел 300 (решение 19.10), добыча в общем расчёте. |
|
||||
| 26.10.2026 | Claude | Создан модуль: жители, еда, прирост, голод, предел жилья, офлайн-прогресс, секция `population`, тесты. |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Модульная система «Богатырей»
|
||||
|
||||
Игра собрана из девяти модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
|
||||
Игра собрана из восьми модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
|
||||
|
||||
```
|
||||
Игровое ядро (Bogatyri.Core)
|
||||
@@ -17,10 +17,9 @@
|
||||
| Модуль | Сборка | Папка | Документ |
|
||||
| --- | --- | --- | --- |
|
||||
| Игровое ядро | `Bogatyri.Core` (без Unity), `Bogatyri.Core.Unity` | `Assets/Scripts/Modules/Core`, `Core.Unity` | [Core.md](Core.md) |
|
||||
| Экономика | `Bogatyri.Economy`, `Bogatyri.Economy.Rules` (без Unity) | `Assets/Scripts/Modules/Economy` | [Economy.md](Economy.md) |
|
||||
| Экономика | `Bogatyri.Economy` | `Assets/Scripts/Modules/Economy` | [Economy.md](Economy.md) |
|
||||
| Поселение | `Bogatyri.Settlement` | `Assets/Scripts/Modules/Settlement` | [Settlement.md](Settlement.md) |
|
||||
| Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.md) |
|
||||
| Население | `Bogatyri.Population` (без Unity) | `Assets/Scripts/Modules/Population` | [Population.md](Population.md) |
|
||||
| Глобальный мир | `Bogatyri.World`, `Bogatyri.World.Simulation` (без Unity) | `Assets/Scripts/Modules/World` | [World.md](World.md) |
|
||||
| Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) |
|
||||
| Сохранения | `Bogatyri.Save` | `Assets/Scripts/Modules/Save` | [Save.md](Save.md) |
|
||||
@@ -51,7 +50,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
|
||||
|
||||
Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026):
|
||||
|
||||
- [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет);
|
||||
- [ ] лимит жилья: 300 жителей при лимите 300 — прирост стоит, построен Дом — прирост идёт;
|
||||
- [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120;
|
||||
- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть);
|
||||
- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу;
|
||||
@@ -61,8 +60,6 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Этапы 1 и 3: склады и лимиты, добыча через рабочих, окно здания и рабочих, улучшение зданий, Склад и Амбар. Тесты `ProductionTests`. Обновлены Economy, Population, Core, Save. |
|
||||
| 26.10.2026 | Claude | Модуль Населения ([Population.md](Population.md)), таблица баланса `EconomyConfig` (`Assets/Resources/EconomyConfig.asset`), часы игры `GameClock`, отладочная панель «DEV». Обновлены Core, Economy, Save. |
|
||||
| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`), обновлён World. |
|
||||
| 23.10.2026 | Claude | Войска переехали в модуль Армии (`ArmyService`, секция `army`), `ArmyBridge` удалён. Обновлены Army, Core, Save. |
|
||||
| 19.10.2026 | Claude | Оставлена одна карта мира (сцена WorldMap). Маленькая карта `Meta/World` удалена с переносом солдат из её походов. Обновлены World и Army. |
|
||||
|
||||
+3
-10
@@ -37,8 +37,6 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
| `world` | Глобальный мир |
|
||||
| `circle` | Круг (простые строки, не JSON — модуль без Unity) |
|
||||
| `army` | Армия (простые строки: `troops=…`, `march=…`) |
|
||||
| `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) |
|
||||
| `production` | Добыча (простые строки: `slots=…`, `eff=…`, `pending=…`) |
|
||||
|
||||
## Формат файла
|
||||
|
||||
@@ -68,13 +66,11 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
|
||||
Что сбрасывает каждый модуль:
|
||||
|
||||
- **Экономика** — стартовые ресурсы из Inspector `ResourceManager` (лимиты склада пересчитает `App/CitySync` по зданиям);
|
||||
- **Добыча** — мест нет (построек нет);
|
||||
- **Экономика** — стартовые ресурсы из Inspector `ResourceManager`;
|
||||
- **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны;
|
||||
- **Мир** — все походы отменены;
|
||||
- **Круг** — игрок ни в каком Круге, его Круги распущены;
|
||||
- **Армия** — войск и походов нет;
|
||||
- **Население** — стартовый город (270 жителей). После сброса `App/GameModules` выдаёт стартовое войско и ресурсы из `EconomyConfig`.
|
||||
- **Армия** — войск и походов нет.
|
||||
|
||||
Новый модуль с данными обязан реализовать `ResetState()`, иначе он не скомпилируется: метод входит в контракт.
|
||||
|
||||
@@ -84,8 +80,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
|
||||
- `GameLoadedEvent` — после загрузки (и когда файла сохранения нет — загрузка завершена, новая игра);
|
||||
- `GameSavedEvent` — после записи;
|
||||
- `GameResetEvent` — после сброса сохранений;
|
||||
- `NewGameStartedEvent` — `GameBootstrap`: файла сохранения нет, первая игра на устройстве.
|
||||
- `GameResetEvent` — после сброса сохранений.
|
||||
|
||||
**Слушает:** ничего.
|
||||
|
||||
@@ -106,8 +101,6 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). |
|
||||
| 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. |
|
||||
| 23.10.2026 | Claude | Новая секция `army` (модуль Армии). `GameLoadedEvent` публикуется и без файла сохранения. |
|
||||
| 19.10.2026 | Claude | Новая секция `circle` (модуль Круга). |
|
||||
| 19.10.2026 | Claude | Сброс сохранений: `ISaveSystem.ResetAll()`, `ResetState()` у модулей, `GameResetEvent`, меню редактора. Кнопка в Настройках теперь сбрасывает и походы, и память Поселения. |
|
||||
|
||||
@@ -264,7 +264,7 @@ PlayerSettings:
|
||||
templateDefaultScene: Assets/Scenes/SampleScene.unity
|
||||
useCustomMainManifest: 0
|
||||
useCustomLauncherManifest: 0
|
||||
useCustomMainGradleTemplate: 0
|
||||
useCustomMainGradleTemplate: 1
|
||||
useCustomLauncherGradleManifest: 0
|
||||
useCustomBaseGradleTemplate: 0
|
||||
useCustomGradlePropertiesTemplate: 0
|
||||
|
||||
Reference in New Issue
Block a user