90 changed files with 444 additions and 6286 deletions
@@ -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,6 +1,5 @@
fileFormatVersion: 2
guid: 2f9b52d13f334e20be4bad9f8a86c74b
folderAsset: yes
guid: afabcf797b4c170459251e1f1c3996b6
DefaultImporter:
externalObjects: {}
userData:
-23
View File
@@ -1,23 +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: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
m_Name: BattleConfig
m_EditorClassIdentifier:
version: 2
counterPercent: 25
wallsPercent: 20
siegeVsWallsPercent: 50
siegeInFieldPercent: -25
loserLossPercent: 50
randomPercent: 5
famineStrengthPercent: 50
fatigueStrengthPercent: 80
-8
View File
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: b666973e44a2442b82f2782adc99852f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
-66
View File
@@ -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":[]}
@@ -1,6 +1,6 @@
fileFormatVersion: 2
guid: 9a5b231268f445349a58134b71372e9b
AssemblyDefinitionImporter:
guid: 3a842e2b03903434ba52aa026b801445
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
@@ -0,0 +1 @@
{"MeasurementCount":-1}
@@ -1,6 +1,6 @@
fileFormatVersion: 2
guid: 253ae5cb48ed4a4a8153210ae03920f0
AssemblyDefinitionImporter:
guid: 2fec63729866dd44483350cddf3c2b84
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
-67
View File
@@ -1,67 +0,0 @@
using UnityEngine;
// ТАБЛИЦА БАЛАНСА БОЯ. Все числа боевой системы -- здесь, а не в коде модуля (ревью от 27.10.2026, п. 2.5).
//
// Ассет: Assets/Resources/BattleConfig.asset (выдели его в окне Project и меняй числа в Inspector,
// перекомпиляция не нужна; изменения действуют со следующего запуска игры). Создать заново:
// правый клик в Project -> Create -> Bogatyri -> Battle Config, имя файла -- BattleConfig, папка -- Resources.
//
// Модуль Боя таблицу не видит: App/GameModules переводит её в BattleRules.
// Сила воинов -- в GameData.Troops (рядом с ценой и временем обучения), здесь её нет.
// Числа -- стартовые для тестирования (план от 27.10.2026), утверждает команда.
[CreateAssetMenu(fileName = "BattleConfig", menuName = "Bogatyri/Battle Config")]
public class BattleConfig : ScriptableObject
{
[Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")]
public int version = 2;
[Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")]
public int counterPercent = 25;
[Tooltip("Стены, %: усиление защитников замка и городов")]
public int wallsPercent = 20;
[Tooltip("Осадные при штурме стен, %")]
public int siegeVsWallsPercent = 50;
[Tooltip("Осадные в поле и в обороне, % (минус)")]
public int siegeInFieldPercent = -25;
[Tooltip("Потери проигравшего, % от каждого типа. Не окончательная формула")]
public int loserLossPercent = 50;
[Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")]
public int randomPercent = 5;
[Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")]
public int famineStrengthPercent = 50;
[Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")]
public int fatigueStrengthPercent = 80;
static BattleConfig cached;
public static BattleConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<BattleConfig>("BattleConfig");
if (cached == null)
{
Debug.LogWarning("[БОЙ] Нет Assets/Resources/BattleConfig.asset -- берутся числа по умолчанию из BattleConfig.cs.");
cached = CreateInstance<BattleConfig>();
}
return cached;
}
// Правила для модуля Боя
public BattleRules ToRules(int[] unitPower)
{
return new BattleRules
{
Version = version,
UnitPower = unitPower,
CounterPercent = counterPercent,
WallsPercent = wallsPercent,
SiegeVsWallsPercent = siegeVsWallsPercent,
SiegeInFieldPercent = siegeInFieldPercent,
LoserLossPercent = loserLossPercent,
RandomPercent = Mathf.Max(0, randomPercent),
FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100),
FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100)
};
}
}
-11
View File
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 7264ce138a844adc81fa69b77a27c8a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
-476
View File
@@ -1,476 +0,0 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь).
// У каждого здания своё окно:
// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+",
// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена;
// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена;
// - Дом: сколько жилья даёт, население / предел, улучшение и цена;
// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения;
// - Кристалл: где добывается, запас / лимит;
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
// кнопка "Нанять войска" (панель "Войска").
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
public static class BuildingWindow
{
static GameObject root;
static GameObject focus; // здание, по которому тапнули (null -- список рабочих)
static readonly List<Action> live = new List<Action>(); // что обновлять, пока окно открыто
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 OnTownHallTapped(TownHall townHall) { Open(townHall != null ? townHall.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) UnityEngine.Object.Destroy(root);
root = null;
focus = null;
live.Clear();
}
static void Rebuild()
{
if (root != null) UnityEngine.Object.Destroy(root);
live.Clear();
var card = UIKit.Modal(TitleFor(focus), out root);
root.AddComponent<BuildingWindowTicker>();
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
if (focus == null) WorkersList(card);
else if (townHall != null) TownHallSection(card, townHall);
else if (barracks != null) BarracksSection(card, barracks);
else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc);
else if (rc != null && rc.IsHouse) HouseSection(card, rc);
else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card);
else if (storage != null) StorageSection(card, storage);
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
}
internal static void Tick()
{
if (root == null) return;
if (focus == null && FocusWasDestroyed) { Close(); return; }
foreach (var update in live) update();
}
static bool focusSet;
static bool FocusWasDestroyed => focusSet && focus == null;
static string TitleFor(GameObject building)
{
focusSet = building != null;
if (building == null) return "РАБОЧИЕ";
var townHall = building.GetComponent<TownHall>();
if (townHall != null) return $"РАТУША — {townHall.currentLevel} УР.";
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 WorkplaceSection(RectTransform card, ResourceCollector rc)
{
if (!WorkJobs.TryFromResource(rc.resourceType, out WorkJob job)) return;
var production = GameModules.Production;
if (production == null || !GameServices.TryGet(out IPopulation population) || !GameServices.TryGet(out IEconomy economy))
{
UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
return;
}
ResourceType res = WorkJobs.Resource(job);
string resName = GameData.ResName(res).ToLower();
double norm = production.Rules.PerWorkerPerHour[(int)job];
// Что даёт это здание
int slots = production.BuildingSlots(rc.level);
double eff = production.BuildingEfficiency(rc.level);
UIKit.Text(card, $"Здание даёт {slots} мест, норма ×{eff:0.##} — до {slots * norm * eff:0.#} {resName} в час при полном штате.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
// Рабочие профессии и кнопки
Header(card, $"Рабочие: {WorkJobs.Name(job).ToLower()}");
Live(card, () => $"{population.GetWorkers(job)} / {production.Slots(job)} мест в городе • свободных жителей: {population.FreeWorkers}",
26, UIKit.Theme.Text);
var row = UIKit.Row(card, 72, 8);
StepButton(row, "−5", job, -5);
StepButton(row, "−1", job, -1);
StepButton(row, "+1", job, +1);
StepButton(row, "+5", job, +5);
// Добыча и склад
Header(card, "Добыча и склад");
Live(card, () =>
{
float rate = production.RatePerHour(res);
string famine = population.IsStarving ? " <color=#ff7a7a>голод: добыча ×0.5</color>" : "";
return $"Сейчас: <color=#9be29b>+{rate:0.#} {resName} в час</color>{famine}";
}, 26, UIKit.Theme.Text);
StorageBar(card, economy, res);
Live(card, () => FillTimeText(economy, res, production.RatePerHour(res)), 22, UIKit.Theme.TextDim);
// Улучшение
int nextSlots = production.BuildingSlots(rc.level + 1);
double nextEff = production.BuildingEfficiency(rc.level + 1);
UpgradeBlock(card, rc.level,
$"мест {slots} → {nextSlots}, норма ×{eff:0.##} → ×{nextEff:0.##}",
() => rc.Level = rc.level + 1);
UIKit.Button(card, "Все рабочие", UIKit.Theme.Blue, () => Open(null), -1, 70, 24);
}
// ---------------- Дом ----------------
static void HouseSection(RectTransform card, ResourceCollector house)
{
int perLevel = EconomyConfig.Load().population.housingPerHouseLevel;
UIKit.Text(card, $"Дом даёт +{perLevel * house.level} к пределу жилья (+{perLevel} за уровень).", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IPopulation population))
{
Header(card, "Население");
LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f,
() => $"{population.Total} / {population.HousingCap} человек", UIKit.Theme.Blue);
Live(card, () => population.Total >= population.HousingCap
? "Предел жилья: прирост стоит, нужны Дома или Ратуша выше"
: $"Прирост: +{population.GrowthPerHour:0.#} человека в час", 22, UIKit.Theme.TextDim);
}
UpgradeBlock(card, house.level, $"жильё +{perLevel * house.level} → +{perLevel * (house.level + 1)}", () => house.Level = house.level + 1);
}
// ---------------- Кристалл ----------------
static void CrystalSection(RectTransform card)
{
UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems);
}
// ---------------- Склад и Амбар ----------------
static void StorageSection(RectTransform card, StorageBuilding storage)
{
var s = EconomyConfig.Load().storage;
bool warehouse = storage.kind == StorageBuilding.Kind.Warehouse;
float perLevel = warehouse ? s.perWarehouseLevel : s.perGranaryLevel;
UIKit.Text(card, warehouse
? $"Склад даёт +{perLevel * storage.level:0} к лимиту дерева, камня, металла и золота."
: $"Амбар даёт +{perLevel * storage.level:0} к лимиту еды.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IEconomy economy))
{
Header(card, "Запас / лимит");
if (warehouse)
{
StorageBar(card, economy, ResourceType.Wood);
StorageBar(card, economy, ResourceType.Stone);
StorageBar(card, economy, ResourceType.Iron);
StorageBar(card, economy, ResourceType.Gold);
}
else StorageBar(card, economy, ResourceType.Food);
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}",
() => storage.Level = storage.level + 1);
}
// ---------------- Ратуша ----------------
static void TownHallSection(RectTransform card, TownHall townHall)
{
var config = EconomyConfig.Load();
int level = townHall.currentLevel;
int next = level + 1;
var p = config.population;
UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IPopulation population))
{
Header(card, "Город");
LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f,
() => $"Население {population.Total} / {population.HousingCap}", UIKit.Theme.Blue);
Live(card, () => $"Жителей {population.Civilians}: рабочих {population.Workers}, свободных {population.Idle} • воинов {population.Soldiers}",
22, UIKit.Theme.TextDim);
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
}
if (level >= townHall.maxLevel)
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
}
// Что даст следующий уровень
Header(card, $"Уровень {next}");
var gains = new List<string> { $"жильё +{p.housingPerTownHallLevel}" };
int nowReserve = ReserveAt(level), nextReserve = ReserveAt(next);
if (nextReserve != nowReserve) gains.Add($"резерв для найма {nowReserve} → {nextReserve}");
UIKit.Text(card, string.Join(", ", gains.ToArray()), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
string unlocks = UnlocksAt(next);
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
bool canPay = townHall.CanUpgrade();
var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
townHall.Upgrade();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
Rebuild();
}, -1, 80, 26);
b.interactable = canPay;
}
// ---------------- Казармы ----------------
static void BarracksSection(RectTransform card, Barracks barracks)
{
var gp = GameProgress.Instance;
UIKit.Text(card, $"Казармы обучают войска. Казарм в городе: {Barracks.Count}, мест в очереди: {Barracks.QueueSize}. " +
$"Новые войска открывает уровень самых прокачанных Казарм ({Barracks.MaxLevel} ур.) вместе с уровнем Ратуши.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (gp != null)
{
Header(card, "Обучение");
Live(card, () =>
{
var d = gp.Data;
if (d.training.Count == 0) return "Никто не обучается";
var o = d.training[0];
string more = d.training.Count > 1 ? $" (ещё заказов: {d.training.Count - 1})" : "";
return $"{GameData.Troops[o.troopIndex].name} ×{o.count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}";
}, 24, UIKit.Theme.Text);
if (GameServices.TryGet(out IPopulation population))
Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim);
Header(card, "Войска");
for (int i = 0; i < GameData.Troops.Count; i++)
{
string lockText = gp.TroopLockText(i);
UIKit.Text(card, lockText.Length == 0
? $"<color=#9be59b>{GameData.Troops[i].name} — открыты</color>"
: $"{GameData.Troops[i].name} — нужно: {lockText}",
24, lockText.Length == 0 ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center);
}
}
// Что откроет следующий уровень этих Казарм
var opens = new List<string>();
if (gp != null)
for (int i = 0; i < GameData.Troops.Count; i++)
if (gp.RequiredBarracksLevel(i) == barracks.level + 1 && Barracks.MaxLevel <= barracks.level) opens.Add(GameData.Troops[i].name);
string effect = opens.Count > 0
? $"откроются: {string.Join(", ", opens.ToArray())} (если хватает уровня Ратуши)"
: "ближе к новым войскам";
UpgradeBlock(card, barracks.level, effect, () => barracks.Level = barracks.level + 1);
UIKit.Button(card, "Нанять войска", UIKit.Theme.Blue, () =>
{
Close();
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
}, -1, 80, 26);
}
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
static int ReserveAt(int townHallLevel)
{
var table = GameModules.Population != null ? GameModules.Population.Rules.CivilianReserveByTownHallLevel : null;
if (table == null || table.Length == 0) return 0;
return table[Mathf.Clamp(townHallLevel, 1, table.Length) - 1];
}
// Здания и войска, которые открываются ровно на этом уровне Ратуши
static string UnlocksAt(int townHallLevel)
{
var names = new List<string>();
if (BuildManager.Instance != null && BuildManager.Instance.allBuildings != null)
foreach (var b in BuildManager.Instance.allBuildings)
if (b != null && b.requiredTownHallLevel == townHallLevel) names.Add(b.buildingName);
foreach (var t in GameData.Troops)
if (t.requiredTownHall == townHallLevel) names.Add(t.name);
return string.Join(", ", names.ToArray());
}
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel)
{
var config = EconomyConfig.Load();
if (level >= config.upgrade.maxLevel)
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
}
Header(card, $"Улучшение до {level + 1} ур.");
UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
var cost = config.UpgradeCost(level);
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
raiseLevel();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Улучшено до {level + 1} уровня");
Rebuild();
}, -1, 80, 26);
b.interactable = canPay;
}
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
static string CostText(ResourceAmount[] cost)
{
GameServices.TryGet(out IEconomy economy);
var parts = new string[cost.Length];
for (int i = 0; i < cost.Length; i++)
{
float have = economy != null ? economy.Get(cost[i].Type) : 0f;
bool ok = have >= cost[i].Amount;
string name = GameData.ResName(cost[i].Type).ToLower();
parts[i] = ok
? $"<color=#9be59b>{cost[i].Amount:0}</color> {name}"
: $"<color=#ff7a7a>{cost[i].Amount:0}</color> {name} (есть {have:0})";
}
return string.Join(", ", parts);
}
// ---------------- Склад: полоска и время до заполнения ----------------
static void StorageBar(RectTransform card, IEconomy economy, ResourceType res)
{
string name = GameData.ResName(res);
LiveBar(card, () =>
{
float cap = economy.GetCapacity(res);
return float.IsInfinity(cap) || cap <= 0 ? 0f : economy.Get(res) / cap;
},
() =>
{
float cap = economy.GetCapacity(res);
return float.IsInfinity(cap) ? $"{name}: {economy.Get(res):0}" : $"{name}: {economy.Get(res):0} / {cap:0}";
},
new Color(0.85f, 0.65f, 0.25f));
}
static string FillTimeText(IEconomy economy, ResourceType res, float ratePerHour)
{
float cap = economy.GetCapacity(res), have = economy.Get(res);
if (float.IsInfinity(cap)) return "";
if (have >= cap) return "<color=#ff7a7a>Склад полон — добыча пропадает. Улучшите Склад или Амбар.</color>";
if (ratePerHour <= 0) return "Добычи нет — поставьте рабочих";
float hours = (cap - have) / ratePerHour;
return hours < 1f ? $"Склад заполнится через {Mathf.CeilToInt(hours * 60)} мин" : $"Склад заполнится через {hours:0.#} ч";
}
// ---------------- Список рабочих по всем профессиям ----------------
static void WorkersList(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;
var row = UIKit.Row(card, 72, 8);
var label = Live(row, () =>
{
int slots = prod.Slots(job);
if (slots <= 0) return $"{WorkJobs.Name(job)}: нет зданий";
return $"{WorkJobs.Name(job)}: {pop.GetWorkers(job)} / {slots} <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>";
}, 24, UIKit.Theme.Text);
label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24;
label.alignment = TextAlignmentOptions.Left;
UIKit.Size(label, -1, 72, 1);
StepButton(row, "−5", job, -5);
StepButton(row, "−1", job, -1);
StepButton(row, "+1", job, +1);
StepButton(row, "+5", job, +5);
}
Live(card, () => $"Свободных жителей: <b>{pop.FreeWorkers}</b> из {pop.Civilians}", 24, UIKit.Theme.Gold);
}
// Кнопка "−" / "+": активна, только если есть кого снять или куда и кого поставить
static void StepButton(Transform row, string label, WorkJob job, int delta)
{
Button b = null;
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);
Tick();
}, 86, 64, 24);
live.Add(() =>
{
if (b == null || !GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) return;
int workers = pop.GetWorkers(job), slots = prod.Slots(job);
b.interactable = delta < 0 ? workers > 0 : slots > workers && pop.FreeWorkers > 0;
});
}
// ---------------- Мелочи ----------------
static void Header(RectTransform card, string text)
{
UIKit.Text(card, text, 28, UIKit.Theme.Gold, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
}
// Текст, который обновляется, пока окно открыто
static TextMeshProUGUI Live(Transform parent, Func<string> text, float size, Color color)
{
var t = UIKit.Text(parent, text(), size, color, TextAlignmentOptions.Center);
live.Add(() => { if (t != null) t.text = text(); });
return t;
}
static void LiveBar(RectTransform card, Func<float> fraction, Func<string> label, Color color)
{
var bar = UIKit.Bar(card, fraction(), color, 34, label());
live.Add(() => { if (bar != null) UIKit.SetBar(bar, fraction(), label()); });
}
}
// Обновляет открытое окно здания раз в полсекунды (добыча идёт, склад наполняется)
public class BuildingWindowTicker : MonoBehaviour
{
float next;
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
BuildingWindow.Tick();
}
}
-11
View File
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 50195a725abc4fe9b74871cd8cc4b273
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
-129
View File
@@ -1,129 +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;
// Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам
// не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем.
production.SetBuildings(city.Workplaces);
population.SetHouseLevels(city.HouseLevels);
population.ClampJobs();
// Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения
if (!population.JobsInitialized) population.AutoFillAll();
}
}
-11
View File
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: fbcccbb94e6f46c89d4c49bf589b9f19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
-165
View File
@@ -1,165 +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()
{
// Панель -- внутри безопасной зоны, чтобы не уходила под вырез камеры
var safe = new GameObject("SafeArea", typeof(RectTransform)).GetComponent<RectTransform>();
safe.SetParent(transform, false);
safe.anchorMin = Vector2.zero;
safe.anchorMax = Vector2.one;
safe.offsetMin = safe.offsetMax = Vector2.zero;
safe.gameObject.AddComponent<SafeArea>();
// Кнопка-переключатель слева посередине: там нет кнопок HUD
var toggle = UIKit.Button(transform, "DEV", new Color(0.8f, 0.2f, 0.8f, 1f), Toggle, 110, 64, 28);
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(safe, 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));
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
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
-11
View File
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: c2cba3db366a45feaaa3e1f4867ae423
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
-217
View File
@@ -1,217 +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;
[Tooltip("Гражданский резерв по уровню Ратуши: [0] -- уровень 1, [1] -- уровень 2... Столько свободных жителей нельзя забрать в войско. Регламент 31.10, ТЕСТ: 1-2 -- 10, 3-5 -- 20, 6-10 -- 30")]
public int[] civilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
}
[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;
[Tooltip("Голод: множитель добычи всех ресурсов, кроме еды (0.5 -- вдвое меньше). Еда добывается без штрафа. Регламент 31.10, ТЕСТ")]
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
}
[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: цена солдата
{
[Tooltip("Сколько жителей стоит один воин (берутся из жителей при начале обучения)")]
public int people = 1;
public float food = 15f;
public float iron = 5f;
public float gold = 5f;
[Tooltip("Время подготовки, секунд. Не утверждено")]
public int trainSeconds = 60;
[Tooltip("Уровень Казарм, с которого открывается тип войск (порядок как в GameData.Troops: пехота, лучники, кавалерия, осадные). " +
"Нужен и уровень Ратуши. Считаются самые прокачанные Казармы. ТЕСТ, не утверждено")]
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
}
[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;
}
}
}
-11
View File
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 4ddb59400c6d4b08b3afaaadc461ec1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+2 -106
View File
@@ -8,19 +8,12 @@ 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()
{
// Боевая система -- чистый C#, сама зарегистрироваться не может.
// Числа боя -- из таблицы BattleConfig, сила воина каждого типа -- из GameData.Troops
var unitPower = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
GameServices.Register<IBattleSystem>(new BattleSystem(BattleConfig.Load().ToRules(unitPower)));
// Боевая система -- чистый C#, сама зарегистрироваться не может
GameServices.Register<IBattleSystem>(new BattleSystem());
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService();
@@ -29,37 +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;
TownHall.Tapped -= BuildingWindow.OnTownHallTapped;
TownHall.Tapped += BuildingWindow.OnTownHallTapped;
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);
@@ -135,72 +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,
CivilianReserveByTownHallLevel = p.civilianReserveByTownHallLevel != null && p.civilianReserveByTownHallLevel.Length > 0
? (int[])p.civilianReserveByTownHallLevel.Clone()
: new PopulationRules().CivilianReserveByTownHallLevel,
PeoplePerSoldier = Mathf.Max(1, config.soldier.people)
};
}
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,
FamineNonFoodMultiplier = p.famineNonFoodMultiplier
};
}
// Новая игра: стартовое войско (пехота) и стартовые ресурсы из EconomyConfig. Один раз на новую игру.
static void GiveStartingKit(PopulationService population, ArmyService army, EconomyConfig config)
{
if (!population.TryTakeStartingKit()) return;
if (population.Rules.StartSoldiers > 0) army.AddTroops(0, population.Rules.StartSoldiers);
if (GameServices.TryGet(out IEconomy economy))
{
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
foreach (var t in types)
{
float target = config.GetStartResource(t);
float current = economy.Get(t);
if (target > current) economy.Add(t, target - current); // не меньше стартового
}
}
Debug.Log($"[НАСЕЛЕНИЕ] Новая игра: {population.Total} человек, воинов {population.Soldiers}.");
}
// Верхняя панель показывает людей как ресурс "Люди" -- держим его равным населению
static void SyncPopulationResource(PopulationChangedEvent e)
{
if (!GameServices.TryGet(out IEconomy economy)) return;
float delta = e.Total - economy.Get(ResourceType.Population);
if (Mathf.Abs(delta) >= 0.5f) economy.Add(ResourceType.Population, delta);
}
static void OnMarchStarted(MarchStartedEvent e)
{
var data = GameProgress.Instance.Data;
-38
View File
@@ -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:
-85
View File
@@ -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:
-35
View File
@@ -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:
+1 -3
View File
@@ -16,9 +16,7 @@ public class CameraController : MonoBehaviour
void Update()
{
// --- Перемещение по зажатой левой кнопке мыши ---
if (UIBlocker.WorldInputCaptured) _isDragging = false; // палец тащит призрак здания -- камера стоит
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI() && !UIBlocker.WorldInputCaptured) // не тащим камеру, когда листаем панель
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI()) // не тащим камеру, когда листаем панель
{
_lastMousePosition = Input.mousePosition;
_isDragging = true;
+8 -38
View File
@@ -2,8 +2,6 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию).
// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается.
public class BuildingUI : MonoBehaviour
{
[Header("Настройки")]
@@ -11,18 +9,8 @@ public class BuildingUI : MonoBehaviour
public TMP_Text stokText;
public Slider progressBar;
float next;
public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true
void Start()
{
if (!ShowLabels)
{
HideLabel();
enabled = false;
return;
}
if (building == null)
{
Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!");
@@ -32,39 +20,21 @@ public class BuildingUI : MonoBehaviour
UpdateUI();
}
// Прячем холст с подписью и полоской. Сам объект здания не трогаем: компонент висит на его корне
void HideLabel()
{
foreach (Component c in new Component[] { stokText, progressBar })
{
if (c == null) continue;
var canvas = c.GetComponentInParent<Canvas>(true);
GameObject target = canvas != null && canvas.gameObject != gameObject ? canvas.gameObject : c.gameObject;
if (target != gameObject) target.SetActive(false);
}
}
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;
}
}
}
+46 -67
View File
@@ -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();
}
}
+5 -27
View File
@@ -5,25 +5,13 @@ using TMPro;
// КАЗАРМЫ -- здание для найма войск.
// - Без казарм нанимать войска нельзя.
// - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5).
// - Уровень Казарм открывает новые типы войск (вместе с уровнем Ратуши; числа -- EconomyConfig, раздел "Солдат", ТЕСТ).
// Считаются самые прокачанные Казармы. Уровень сохраняет модуль Поселения (ILeveledBuilding).
// - Тап по казармам открывает окно Казарм (App/BuildingWindow): очередь, войска, улучшение, кнопка "Нанять войска".
// - Подписи над зданием больше нет -- всё в окне (вернуть: ShowLabel = true).
// - Нажатие на казармы открывает панель "Войска" (найм).
// - Над зданием подпись: что сейчас обучается и сколько осталось.
// Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels).
public class Barracks : MonoBehaviour, ILeveledBuilding
public class Barracks : MonoBehaviour
{
public static readonly List<Barracks> All = new List<Barracks>();
public static int Count { get { return All.Count; } }
public static bool ShowLabel = false;
public int level = 1;
public int Level { get => level; set => level = Mathf.Max(1, value); }
// Уровень самых прокачанных Казарм в городе (0 -- казарм нет)
public static int MaxLevel
{
get { int m = 0; foreach (var b in All) if (b != null && b.level > m) m = b.level; return m; }
}
// Размер очереди обучения в зависимости от числа казарм
public static int QueueSize { get { return Count == 0 ? 0 : Mathf.Min(5, 2 + Count); } }
@@ -36,7 +24,6 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
void Start()
{
if (!ShowLabel) return;
// подпись над зданием (обычный 3D-текст, не UI)
var go = new GameObject("BarracksLabel");
go.transform.SetParent(transform, false);
@@ -57,7 +44,6 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
void Update()
{
if (label == null) return;
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
Refresh();
@@ -78,18 +64,10 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
}
Vector3 downPos;
void OnMouseDown()
{
downPos = Input.mousePosition;
}
// Тап (а не перетаскивание камеры) -- окно Казарм
void OnMouseUpAsButton()
{
if (UIBlocker.IsPointerOverUI()) return;
if ((Input.mousePosition - downPos).magnitude > 25f) return;
BuildingWindow.Open(gameObject);
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
}
}
+63 -161
View File
@@ -3,18 +3,14 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
// СВОБОДНАЯ СТРОЙКА НА СЕТКЕ (игрок сам выбирает место):
// 1. Кнопка "СТРОЙКА" включает режим: на земле появляется сетка (красные клетки заняты), сверху подсказка,
// и сразу открывается список зданий.
// 2. Выбрали здание -> появляется призрак: полупрозрачное здание и пятно его клеток.
// Зелёное пятно -- можно ставить, красное -- занято или за границей сетки.
// 3. Призрак двигают пальцем (тащить за само пятно) или тапом по земле -- он прыгает туда.
// Камера в это время не ездит. Размер у зданий разный (BuildingData.footprint).
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
// НОВАЯ СХЕМА СТРОЙКИ (как в Rise of Kingdoms):
// 1. Кнопка "СТРОЙКА" включает режим строительства -- появляются свободные участки и подсказка сверху.
// 2. Тап по свободному участку -> окно со зданиями (цена, что доступно по уровню Ратуши).
// 3. "Построить" -> здание встаёт на участок. Режим остаётся включённым, можно строить дальше.
// 4. Кнопка "ВЫЙТИ" (та же кнопка или в подсказке) -- выключает режим, участки снова прячутся.
// Вне режима пустые участки не видны и не мешают, при запуске игры ничего не открывается.
//
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
public class BuildModeController : MonoBehaviour
{
public static BuildModeController Instance { get; private set; }
@@ -28,27 +24,14 @@ public class BuildModeController : MonoBehaviour
GameObject banner;
GameObject chooser;
GameObject placePanel;
TextMeshProUGUI placeInfo;
Button placeButton;
BuildGhost ghost;
bool dragging;
Vector2 grabOffset;
Vector2 downPos;
bool downOverUI;
bool downOnGhost;
float nextPanelRefresh;
float nextVisualRefresh;
const float TapThreshold = 25f;
static readonly Color SlotColor = new Color(0.3f, 1f, 0.4f, 0.75f);
void Awake() { Instance = this; }
void OnDestroy()
{
if (Instance == this) Instance = null;
UIBlocker.WorldInputCaptured = false;
}
void OnDestroy() { if (Instance == this) Instance = null; }
void Start()
{
@@ -62,172 +45,85 @@ public class BuildModeController : MonoBehaviour
{
IsActive = active;
CloseChooser();
CancelGhost();
ApplySlotVisuals();
UpdateButtonLabel();
BuildGrid.Instance.ShowOverlay(active);
if (banner != null) { Destroy(banner); banner = null; }
if (active)
{
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
banner = CreateBanner();
OpenChooser();
if (FreeSlots().Count == 0) UIKit.Toast("Свободных участков нет. Улучшайте Ратушу и сносите старое.");
}
}
void Update()
{
if (Time.unscaledTime >= nextVisualRefresh)
{
nextVisualRefresh = Time.unscaledTime + 0.3f;
ApplySlotVisuals(); // участки могли освободиться/заняться (снос, загрузка сохранения)
}
if (!IsActive) return;
// открыли любую панель HUD -- выходим из режима стройки
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; }
if (chooser != null || ghost == null) { StopDrag(); return; }
HandlePointer();
if (Time.unscaledTime >= nextPanelRefresh)
{
nextPanelRefresh = Time.unscaledTime + 0.3f;
ghost.Recheck(); // клетки могли заняться (загрузка сохранения) или ресурсы -- прийти
RefreshPlacePanel();
}
}
// ---------------- палец: тащим призрак или тапаем по земле ----------------
void HandlePointer()
{
Vector2 world = PointerWorld();
if (chooser != null) return;
// Тап = нажали и отпустили почти на том же месте (чтобы перетаскивание камеры не считалось тапом)
if (Input.GetMouseButtonDown(0))
{
downPos = Input.mousePosition;
downOverUI = UIBlocker.IsPointerOverUI();
downOnGhost = !downOverUI && ghost.Contains(world);
if (downOnGhost)
{
dragging = true;
grabOffset = (Vector2)ghost.transform.position - world;
UIBlocker.WorldInputCaptured = true; // камера не поедет
}
}
if (dragging && Input.GetMouseButton(0))
{
var before = ghost.Cell;
ghost.MoveNear(world + grabOffset);
if (ghost.Cell != before) RefreshPlacePanel();
}
if (Input.GetMouseButtonUp(0))
{
bool wasDragging = dragging;
StopDrag();
if (wasDragging || downOverUI || UIBlocker.IsPointerOverUI()) return;
if (((Vector2)Input.mousePosition - downPos).magnitude > TapThreshold) return; // тащили камеру
ghost.MoveNear(world); // тап по земле -- призрак прыгает сюда
RefreshPlacePanel();
Vector2 up = Input.mousePosition;
if (downOverUI || UIBlocker.IsPointerOverUI() || (up - downPos).magnitude > 25f) return;
HandleTap(up);
}
}
void StopDrag()
void HandleTap(Vector2 screenPos)
{
dragging = false;
UIBlocker.WorldInputCaptured = false;
}
static Vector2 PointerWorld()
{
var cam = Camera.main;
if (cam == null) return Vector2.zero;
Vector3 w = cam.ScreenToWorldPoint(Input.mousePosition);
return new Vector2(w.x, w.y);
}
// ---------------- призрак ----------------
void StartGhost(BuildingData data)
{
CancelGhost();
CloseChooser();
ghost = BuildGhost.Create(data);
// ставим в центр экрана, а если там занято -- на ближайшее свободное место
var cam = Camera.main;
Vector2 center = cam != null ? (Vector2)cam.transform.position : Vector2.zero;
if (BuildGrid.Instance.FindFreeNear(center, ghost.Footprint.size, out var cell)) ghost.MoveToCell(cell);
else
if (BuildManager.Instance == null) return;
var slot = BuildManager.Instance.SlotAtScreen(screenPos);
if (slot == null) return;
if (slot.isOccupied)
{
ghost.MoveNear(center);
UIKit.Toast("Свободного места под это здание нет");
var id = slot.occupyingBuilding != null ? slot.occupyingBuilding.GetComponent<PlacedBuildingId>() : null;
UIKit.Toast("Участок занят" + (id != null ? ": " + id.buildingName : ""));
return;
}
CreatePlacePanel();
OpenChooser(slot);
}
void CancelGhost()
// ---------------- участки ----------------
List<BuildSlot> FreeSlots()
{
StopDrag();
if (ghost != null) Destroy(ghost.gameObject);
ghost = null;
if (placePanel != null) Destroy(placePanel);
placePanel = null;
var list = new List<BuildSlot>();
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None)) if (!s.isOccupied) list.Add(s);
return list;
}
void Place()
void ApplySlotVisuals()
{
if (ghost == null || BuildManager.Instance == null) return;
var d = ghost.Data;
ghost.Recheck();
if (!ghost.CellsFree) { UIKit.Toast("Здесь строить нельзя: клетки заняты"); return; }
if (!d.CanAfford()) { UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); return; }
if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null)
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None))
{
UIKit.Toast("Построено: " + d.buildingName);
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
var sr = s.GetComponent<SpriteRenderer>();
if (sr == null) continue;
bool show = IsActive && !s.isOccupied;
if (sr.enabled != show) sr.enabled = show;
if (show) sr.color = SlotColor;
}
else UIKit.Toast("Не получилось построить");
}
// ---------------- нижняя панель: Поставить / Другое здание / Отмена ----------------
void CreatePlacePanel()
{
if (placePanel != null) Destroy(placePanel);
var bar = UIKit.Row(UIKit.Overlay, -1, 14, new Color(0.08f, 0.1f, 0.14f, 0.94f), 14);
bar.name = "BuildPlacePanel";
bar.anchorMin = new Vector2(0.04f, 0); bar.anchorMax = new Vector2(0.96f, 0);
bar.pivot = new Vector2(0.5f, 0);
bar.sizeDelta = new Vector2(0, 130); bar.anchoredPosition = new Vector2(0, 40);
placePanel = bar.gameObject;
placeInfo = UIKit.Text(bar, "", 26, Color.white, TextAlignmentOptions.Left);
UIKit.Size(placeInfo, -1, -1, 1);
placeButton = UIKit.Button(bar, "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26);
RefreshPlacePanel();
}
void RefreshPlacePanel()
{
if (ghost == null || placeInfo == null) return;
var d = ghost.Data;
bool afford = d.CanAfford();
string where = ghost.CellsFree ? "<color=#9be59b>место свободно</color>" : "<color=#ff7a7a>клетки заняты</color>";
placeInfo.text = "<b>" + d.buildingName + "</b> " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y +
" кл. • " + where + "\n" + CostText(d);
bool ok = ghost.CellsFree && afford;
UIKit.SetButton(placeButton, ok ? "Поставить" : (ghost.CellsFree ? "Мало ресурсов" : "Занято"), ok);
}
// ---------------- окно выбора здания ----------------
void OpenChooser()
void OpenChooser(BuildSlot slot)
{
CloseChooser();
StopDrag();
var bm = BuildManager.Instance;
if (bm == null) return;
GameObject root;
var card = UIKit.Modal("Что построить?", out root);
chooser = root;
@@ -239,7 +135,7 @@ public class BuildModeController : MonoBehaviour
card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero;
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
UIKit.Text(card, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UIKit.Text(card, "Участок: " + slot.name + " • Ратуша " + th + " ур.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var holder = UIKit.Make("ListHolder", card);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
@@ -248,7 +144,7 @@ public class BuildModeController : MonoBehaviour
// доступные сверху, закрытые по уровню Ратуши -- ниже
var all = new List<BuildingData>(bm.allBuildings);
all.RemoveAll(b => b == null || b.prefab == null);
all.RemoveAll(b => b == null);
all.Sort((a, b) => a.requiredTownHallLevel.CompareTo(b.requiredTownHallLevel));
foreach (var data in all)
@@ -256,7 +152,6 @@ public class BuildModeController : MonoBehaviour
var d = data;
bool locked = d.requiredTownHallLevel > th;
bool afford = !locked && d.CanAfford();
var size = BuildFootprint.Of(d).size;
var row = UIKit.Row(list, 120, 14, locked ? UIKit.Theme.CardDark : UIKit.Theme.Card, 10);
var icon = UIKit.Box(row, d.icon != null ? Color.white : UIKit.Theme.Gray);
@@ -265,18 +160,26 @@ public class BuildModeController : MonoBehaviour
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>", 30,
locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
UIKit.Text(info, d.buildingName, 30, locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
string label = locked ? "Закрыто" : "Выбрать";
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
string label = locked ? "Закрыто" : (afford ? "Построить" : "Мало ресурсов");
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (BuildManager.Instance.BuildOnSlot(d, slot))
{
UIKit.Toast("Построено: " + d.buildingName);
CloseChooser();
ApplySlotVisuals();
if (FreeSlots().Count == 0) SetMode(false);
}
else UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost)));
}, 230, 90, 26);
b.interactable = !locked;
}
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
UIKit.Button(card, "Отмена", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
}
void CloseChooser()
@@ -313,13 +216,12 @@ public class BuildModeController : MonoBehaviour
// полоска сверху экрана (не модальная: остальной экран остаётся кликабельным)
var bar = UIKit.Row(UIKit.Overlay, -1, 16, new Color(0.1f, 0.25f, 0.12f, 0.92f), 14);
bar.name = "BuildModeBanner";
bar.anchorMin = new Vector2(0.1f, 1); bar.anchorMax = new Vector2(0.9f, 1);
bar.anchorMin = new Vector2(0.15f, 1); bar.anchorMax = new Vector2(0.85f, 1);
bar.pivot = new Vector2(0.5f, 1);
bar.sizeDelta = new Vector2(0, 110); bar.anchoredPosition = new Vector2(0, -150);
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: двигайте здание по сетке, красное -- занято", 26, Color.white, TextAlignmentOptions.Left);
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: нажмите на зелёный участок", 30, Color.white, TextAlignmentOptions.Left);
UIKit.Size(t, -1, -1, 1);
UIKit.Button(bar, "Здания", UIKit.Theme.Green, OpenChooser, 180, 80, 28);
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 160, 80, 28);
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 180, 80, 28);
return bar.gameObject;
}
+4 -50
View File
@@ -622,12 +622,6 @@ public class GameProgress : MonoBehaviour
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
static IArmy Army => GameServices.Get<IArmy>();
// Люди города -- модуль Населения. null -- модуль не подключён
static IPopulation Population => GameServices.TryGet(out IPopulation p) ? p : null;
// Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма)
public int Recruitable => Population != null ? Population.Recruitable : int.MaxValue;
public int TroopCount(int type) { return Army.GetTroops(type); }
public int TotalTroops
@@ -635,51 +629,20 @@ public class GameProgress : MonoBehaviour
get { return Army.SoldiersAtHome; }
}
// Тип войск открыт, если выполнены оба условия: уровень Ратуши (GameData.Troops) и уровень Казарм
// (EconomyConfig, раздел "Солдат", ТЕСТ). Считаются самые прокачанные Казармы.
public bool TroopUnlocked(int index)
{
return TownHallLevel >= GameData.Troops[index].requiredTownHall && Barracks.MaxLevel >= RequiredBarracksLevel(index);
}
public int RequiredBarracksLevel(int index)
{
var table = EconomyConfig.Load().soldier.requiredBarracksLevel;
return table != null && index >= 0 && index < table.Length ? Mathf.Max(1, table[index]) : 1;
}
// Чего не хватает, чтобы открыть тип войск: "Ратуша 2 ур., Казармы 3 ур." (пусто -- открыт)
public string TroopLockText(int index)
{
var parts = new List<string>();
int th = GameData.Troops[index].requiredTownHall, bk = RequiredBarracksLevel(index);
if (TownHallLevel < th) parts.Add("Ратуша " + th + " ур.");
if (Barracks.MaxLevel < bk) parts.Add("Казармы " + bk + " ур.");
return string.Join(", ", parts.ToArray());
}
public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; }
public void Train(int index, int count)
{
var def = GameData.Troops[index];
if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; }
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; }
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
// Воин -- житель города (модуль Населения): берутся свободные жители, резерв не отдаётся
var population = Population;
if (population != null && population.Recruitable < count)
{
UIKit.Toast(population.Recruitable > 0
? "Жителей хватит только на " + population.Recruitable + " воинов"
: "Некого нанять: свободных жителей нет (резерв " + population.CivilianReserve + "). Снимите рабочих или стройте Дома");
return;
}
if (!Pay(def.cost, count)) return;
bool recruited = population != null && population.TryRecruit(count);
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
if (start < Now) start = Now;
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited });
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end });
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
MarkChanged();
}
@@ -718,16 +681,7 @@ public class GameProgress : MonoBehaviour
{
var o = Data.training[0];
Data.training.RemoveAt(0);
if (o.recruited && Population != null)
{
// Люди из Казарм переходят в Армию одним действием: не теряются и не дублируются
if (!Population.CompleteTraining(o.troopIndex, o.count))
{
Debug.LogWarning($"[ВОЙСКА] Армия не приняла {o.count} × {GameData.Troops[o.troopIndex].name} -- новобранцы остаются в Казармах");
continue; // воинов не прибавилось -- и в статистику не идёт
}
}
else Army.AddTroops(o.troopIndex, o.count); // старый заказ: людей под него не брали
Army.AddTroops(o.troopIndex, o.count);
Data.troopsTrained += o.count;
Data.dailyTroops += o.count;
Data.eventTroopRemainder += o.count;
-1
View File
@@ -24,7 +24,6 @@ public class TrainingOrder
public int count;
public long startTicks;
public long endTicks;
public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали
}
[Serializable]
+2 -33
View File
@@ -24,32 +24,10 @@ public class TroopsPanel : BasePanel
ProfilePanel.Header(army, "Армия");
ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0"));
ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get<IArmy>().TroopsInMarches.ToString("N0"));
if (GameServices.TryGet(out IPopulation population))
{
// Воины берутся из свободных жителей (модуль Населения), резерв не нанять
ProfilePanel.KV(army, "Обучаются в Казармах", population.InTraining.ToString("N0"));
ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") +
" (резерв " + population.CivilianReserve.ToString("N0") + ")");
}
ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0"));
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26);
// --- Гарнизон: часть воинов охраняет город и в походы не уходит ---
IArmy troopsArmy = GameServices.Get<IArmy>();
var garrison = UIKit.Card(list, UIKit.Theme.CardDark);
ProfilePanel.Header(garrison, "Гарнизон города");
ProfilePanel.KV(garrison, "В гарнизоне", troopsArmy.GarrisonTotal.ToString("N0") + " из " + troopsArmy.SoldiersAtHome.ToString("N0"));
ProfilePanel.KV(garrison, "Свободны для походов", troopsArmy.AvailableForMarch.ToString("N0"));
UIKit.Text(garrison, "Гарнизон защищает город и не уходит в походы. Воины в нём не пропадают: всего воинов столько же.", 22, UIKit.Theme.TextDim);
var gRow = UIKit.Row(garrison, 80, 10);
GarrisonButton(gRow, troopsArmy, "0", 0);
GarrisonButton(gRow, troopsArmy, "-10", troopsArmy.GarrisonTotal - 10);
GarrisonButton(gRow, troopsArmy, "-1", troopsArmy.GarrisonTotal - 1);
GarrisonButton(gRow, troopsArmy, "+1", troopsArmy.GarrisonTotal + 1);
GarrisonButton(gRow, troopsArmy, "+10", troopsArmy.GarrisonTotal + 10);
GarrisonButton(gRow, troopsArmy, "Все", troopsArmy.SoldiersAtHome);
// --- Нет казарм -- нанимать негде ---
if (Barracks.Count == 0)
{
@@ -104,7 +82,7 @@ public class TroopsPanel : BasePanel
if (!unlocked)
{
UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
UIKit.Text(card, "Откроется на Ратуше " + def.requiredTownHall + " уровня", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
continue;
}
@@ -112,22 +90,13 @@ public class TroopsPanel : BasePanel
foreach (int amount in Amounts)
{
int a = amount;
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize && GP.Recruitable >= a;
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize;
var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 80, 30);
UIKit.Size(b, -1, 80, 1);
}
}
}
// Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении
static void GarrisonButton(Transform row, IArmy army, string label, int target)
{
target = Mathf.Clamp(target, 0, army.SoldiersAtHome);
bool can = target != army.GarrisonTotal;
var b = UIKit.Button(row, label, can ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { if (can) army.SetGarrison(target); }, -1, 80, 28);
UIKit.Size(b, -1, 80, 1);
}
protected override void Tick()
{
var d = GP.Data;
-12
View File
@@ -64,23 +64,11 @@ public abstract class BasePanel : MonoBehaviour
t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24);
}
content = (RectTransform)t;
WrapInSafeArea(content);
}
return content;
}
}
// Фон окна -- на весь экран, а содержимое -- в безопасной зоне: на iPhone левый край уходил под вырез камеры.
// Content переезжает в растянутый контейнер с SafeArea и сохраняет свои отступы.
void WrapInSafeArea(RectTransform rt)
{
if (rt.parent != transform) return; // уже обёрнут
var safe = UIKit.Stretch(UIKit.Make("SafeArea", transform));
safe.SetSiblingIndex(rt.GetSiblingIndex());
safe.gameObject.AddComponent<SafeArea>().fitInsideParent = true; // окно -- не на весь экран
rt.SetParent(safe, false);
}
void Rebuild()
{
float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f;
+16 -85
View File
@@ -8,7 +8,6 @@ using System.Text;
//
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
@@ -20,7 +19,6 @@ public sealed class ArmyService : IArmy, ISaveParticipant
}
private readonly int[] troops = new int[Types];
private readonly int[] garrison = new int[Types];
private readonly List<MarchUnits> marches = new List<MarchUnits>();
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
@@ -50,28 +48,6 @@ public sealed class ArmyService : IArmy, ISaveParticipant
}
}
public int GarrisonTotal
{
get { int n = 0; foreach (var g in garrison) n += g; return n; }
}
public int GetGarrison(int type)
{
return type >= 0 && type < Types ? garrison[type] : 0;
}
public int AvailableForMarch => SoldiersAtHome - GarrisonTotal;
// Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника
public int SetGarrison(int count)
{
count = Math.Max(0, Math.Min(count, SoldiersAtHome));
int[] units = count > 0 ? ArmyMath.TakeProportional(troops, count) : null;
for (int i = 0; i < Types; i++) garrison[i] = units != null ? units[i] : 0;
Changed();
return count;
}
public void AddTroops(int type, int count)
{
if (type < 0 || type >= Types || count <= 0) return;
@@ -91,73 +67,41 @@ public sealed class ArmyService : IArmy, ISaveParticipant
troops[i] -= l;
lost += l;
}
ClampGarrison();
if (lost > 0) Changed();
return lost;
}
public bool TakeForMarch(int marchId, int count)
{
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
if (marches.Exists(m => m.marchId == marchId)) return false;
// В поход -- только свободные воины, гарнизон остаётся в городе
var free = new int[Types];
for (int i = 0; i < Types; i++) free[i] = troops[i] - garrison[i];
int[] taken = ArmyMath.TakeProportional(free, count);
int[] taken = ArmyMath.TakeProportional(troops, count);
if (taken == null) return false;
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
marches.RemoveAll(m => m.marchId == marchId);
marches.Add(new MarchUnits { marchId = marchId, units = taken });
Changed();
return true;
}
// Воины возвращаются только из известного похода, и только один раз: запись похода удаляется.
// Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты
// появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy.
public bool ReturnFromMarch(int marchId, int survivors)
public void ReturnFromMarch(int marchId, int survivors)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
int sent = 0;
foreach (var u in entry.units) sent += u;
int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся
for (int i = 0; i < Types; i++) troops[i] += back[i];
marches.Remove(entry);
if (entry == null)
{
troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой
}
else
{
int[] back = ArmyMath.SplitSurvivors(entry.units, survivors);
for (int i = 0; i < Types; i++) troops[i] += back[i];
marches.Remove(entry);
}
Changed();
return true;
}
public int[] GetMarchUnits(int marchId)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
return entry != null ? (int[])entry.units.Clone() : null;
}
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
public bool ReturnUnits(int marchId, int[] survivorUnits)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++)
troops[i] += Math.Max(0, Math.Min(survivorUnits[i], entry.units[i]));
marches.Remove(entry);
Changed();
return true;
}
void ClampGarrison()
{
for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i]));
}
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal });
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
}
// ---------------- Перенос из старого progress.json ----------------
@@ -168,14 +112,12 @@ public sealed class ArmyService : IArmy, ISaveParticipant
{
if (legacyTroops != null)
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
Array.Clear(garrison, 0, Types);
marches.Clear();
if (legacyMarches != null)
{
foreach (var m in legacyMarches)
{
if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись
var units = new int[Types];
for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]);
marches.Add(new MarchUnits { marchId = m.Key, units = units });
@@ -187,7 +129,6 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// ---------------- Сохранение (секция "army") ----------------
// Модуль без Unity, поэтому формат -- простые строки:
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
public string SaveKey => "army";
@@ -196,7 +137,6 @@ public sealed class ArmyService : IArmy, ISaveParticipant
{
var sb = new StringBuilder();
sb.Append("troops=").Append(Join(troops)).Append('\n');
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
foreach (var m in marches)
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
return sb.ToString();
@@ -205,7 +145,6 @@ public sealed class ArmyService : IArmy, ISaveParticipant
public void Restore(string data)
{
Array.Clear(troops, 0, Types);
Array.Clear(garrison, 0, Types);
marches.Clear();
RestoredFromSave = true;
@@ -219,29 +158,21 @@ public sealed class ArmyService : IArmy, ISaveParticipant
int[] t = Parse(line.Substring("troops=".Length));
Array.Copy(t, troops, Types);
}
else if (line.StartsWith("garrison="))
{
int[] g = Parse(line.Substring("garrison=".Length));
Array.Copy(g, garrison, Types);
}
else if (line.StartsWith("march="))
{
string[] parts = line.Substring("march=".Length).Split(':');
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id))
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
}
}
}
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
Changed();
}
// Новая игра: войск нет, гарнизона и походов нет
// Новая игра: войск нет, походов нет
public void ResetState()
{
Array.Clear(troops, 0, Types);
Array.Clear(garrison, 0, Types);
marches.Clear();
Changed();
}
@@ -1,29 +0,0 @@
// ПРАВИЛА БОЯ: все числа боевой системы в одном месте. Документ: Docs/Modules/Battle.md
// Без Unity. В игре значения приходят из таблицы App/BattleConfig (Assets/Resources/BattleConfig.asset),
// App/GameModules переводит её в BattleRules. Без таблицы (тесты, сервер) -- значения по умолчанию ниже.
//
// Числа -- стартовые для тестирования (план от 27.10.2026). Изменил правила -- подними Version:
// итог боя запоминает версию, по которой посчитан.
public sealed class BattleRules
{
public int Version = 2; // 2 -- добавлены голод и усталость
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
public int[] UnitPower = { 4, 5, 9, 16 };
public int CounterPercent = 25; // контра: пехота > кавалерия > лучники > пехота
public int WallsPercent = 20; // защитникам за стенами
public int SiegeVsWallsPercent = 50; // осадным при штурме стен
public int SiegeInFieldPercent = -25; // осадным в поле и в обороне
public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула)
public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5%
// Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40%
public int FamineStrengthPercent = 50; // голод: -50%
public int FatigueStrengthPercent = 80; // усталость: -20%
public static BattleRules Default => new BattleRules();
// Без случайности: для тестов, где важен точный итог
public static BattleRules NoRandom => new BattleRules { RandomPercent = 0 };
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 6a836fd9caa34954b185ca38b4d89afb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+11 -201
View File
@@ -1,216 +1,26 @@
using System;
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
//
// Правила (стартовые числа -- в BattleRules, утверждает команда):
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
// бонус типа против врага взвешивается по составу врага;
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог;
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие.
// Правила пока самые простые: побеждает тот, у кого больше солдат.
// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся.
public sealed class BattleSystem : IBattleSystem
{
private readonly BattleRules rules;
private readonly int[] power;
public BattleRules Rules => rules;
public BattleSystem() : this(BattleRules.Default) { }
public BattleSystem(BattleRules rules)
{
this.rules = rules ?? BattleRules.Default;
power = new int[TroopType.Count];
var defaults = BattleRules.Default.UnitPower;
for (int i = 0; i < TroopType.Count; i++)
power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
? this.rules.UnitPower[i] : defaults[i];
}
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
public int Counter(int attackerType, int defenderType)
{
if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return rules.CounterPercent;
if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return rules.CounterPercent;
if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return rules.CounterPercent;
return 0;
}
public BattleOutcome Resolve(BattleRequest request)
{
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2));
}
int attackers = request.Attackers;
int defenders = request.Defenders;
bool victory = attackers > defenders; // ничья -- в пользу защитника
// Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается.
public BattleForecast Forecast(BattleRequest request)
{
int r = rules.RandomPercent * 10;
var f = new BattleForecast
{
AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue,
AttackerLossesMin = int.MaxValue
};
bool anyWin = false, anyLoss = false;
foreach (int attLuck in new[] { -r, r })
foreach (int defLuck in new[] { -r, r })
{
BattleOutcome o = Fight(request, attLuck, defLuck);
if (o.Victory) anyWin = true; else anyLoss = true;
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, o.AttackerPower);
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, o.AttackerPower);
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, o.DefenderPower);
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, o.DefenderPower);
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, o.AttackerLosses);
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, o.AttackerLosses);
}
f.Chance = !anyLoss ? BattleChance.Advantage : !anyWin ? BattleChance.HighRisk : BattleChance.Uncertain;
return f;
}
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille)
{
int[] att = Units(request.AttackerUnits, request.Attackers);
int[] def = Units(request.DefenderUnits, request.Defenders);
int attCondition = ConditionPercent(request.AttackerConditions);
int defCondition = ConditionPercent(request.DefenderConditions);
long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attCondition, attLuckPermille);
long defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defCondition, defLuckPermille);
bool victory = attPower > defPower; // ничья -- в пользу защитника
// Потери в процентах: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя)
long stronger = Math.Max(attPower, defPower);
long weaker = Math.Min(attPower, defPower);
int[] attDead = Losses(att, weaker, stronger, isLoser: !victory);
int[] defDead = Losses(def, weaker, stronger, isLoser: victory);
// Раненых пока нет: доля раненых и погибших не утверждена (план от 27.10.2026)
int[] attWounded = new int[TroopType.Count];
int[] defWounded = new int[TroopType.Count];
var attAlive = new int[TroopType.Count];
var defAlive = new int[TroopType.Count];
var defOut = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attAlive[i] = att[i] - attDead[i] - attWounded[i];
defAlive[i] = def[i] - defDead[i] - defWounded[i];
defOut[i] = defDead[i] + defWounded[i];
}
// Проигравший теряет половину своего войска, победитель -- столько же
int loserArmy = victory ? defenders : attackers;
int losses = (loserArmy + 1) / 2;
return new BattleOutcome
{
Victory = victory,
BattleId = request.Seed,
RulesVersion = rules.Version,
AttackerLosses = Sum(attDead) + Sum(attWounded),
DefenderLosses = Sum(defOut),
Survivors = Sum(attAlive),
AttackerPower = attPower,
DefenderPower = defPower,
AttackerLuckPermille = attLuckPermille,
DefenderLuckPermille = defLuckPermille,
AttackerConditionPercent = attCondition,
DefenderConditionPercent = defCondition,
AttackerUnitsBefore = att,
DefenderUnitsBefore = def,
AttackerSurvivorUnits = attAlive,
DefenderSurvivorUnits = defAlive,
AttackerDeadUnits = attDead,
DefenderDeadUnits = defDead,
AttackerWoundedUnits = attWounded,
DefenderWoundedUnits = defWounded,
DefenderLossUnits = defOut
AttackerLosses = losses,
DefenderLosses = losses,
Survivors = attackers - losses
};
}
// Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 40%
public int ConditionPercent(BattleCondition conditions)
{
int percent = 100;
if ((conditions & BattleCondition.Famine) != 0) percent = percent * rules.FamineStrengthPercent / 100;
if ((conditions & BattleCondition.Fatigue) != 0) percent = percent * rules.FatigueStrengthPercent / 100;
return Math.Max(0, percent);
}
// Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах.
// Порядок: контры и осадные -> бонусы стороны -> состояния (голод, усталость) -> случайность
// (порядок бонусов и случайности -- открытый вопрос плана).
long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int conditionPercent, int luckPermille)
{
int enemyTotal = Sum(enemy);
long total = 0;
for (int i = 0; i < TroopType.Count; i++)
{
if (units[i] <= 0) continue;
long counterSum = 0;
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0;
int siegePercent = 0;
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent);
}
total = Math.Max(0, total * (100 + bonusPercent) / 100);
total = total * conditionPercent / 100;
return total * (1000 + luckPermille) / 1000;
}
// Случайность стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
// Считается из Seed (SplitMix64) -- одинаково на любой платформе, System.Random для этого не годится.
public int Luck(long seed, int side)
{
int range = rules.RandomPercent * 10;
if (range <= 0) return 0;
ulong x = (ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side;
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
x ^= x >> 31;
return (int)(x % (ulong)(2 * range + 1)) - range;
}
// Потери каждого типа: проигравший -- LoserLossPercent; победитель -- LoserLossPercent x ratioNum / ratioDen.
// Округление вниз: одиночный проигравший не гибнет -- открытый вопрос (Battle.md).
int[] Losses(int[] units, long ratioNum, long ratioDen, bool isLoser)
{
var lost = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
if (units[i] <= 0) continue;
long l = isLoser
? (long)units[i] * rules.LoserLossPercent / 100
: ratioDen > 0 ? (long)units[i] * rules.LoserLossPercent * ratioNum / (100 * ratioDen) : 0;
lost[i] = (int)Math.Max(0, Math.Min(units[i], l));
}
return lost;
}
static int[] Units(int[] units, int count)
{
var result = new int[TroopType.Count];
if (units != null)
{
for (int i = 0; i < TroopType.Count && i < units.Length; i++) result[i] = Math.Max(0, units[i]);
}
else
{
result[TroopType.Infantry] = Math.Max(0, count); // состав неизвестен -- считаем пехотой
}
return result;
}
static int Sum(int[] values)
{
int n = 0;
foreach (var v in values) n += v;
return n;
}
}
@@ -11,7 +11,6 @@ public static class CoreUnityBootstrap
GameServices.Clear();
EventBus.Clear();
SaveRegistry.Clear();
GameClock.ResetOffset(); // отладочная перемотка не переживает перезапуск
EventBus.OnHandlerError = Debug.LogException;
}
}
@@ -6,10 +6,6 @@ using UnityEngine.EventSystems;
// не собирал ресурсы со здания под панелью, не улучшал Ратушу, не двигал камеру.
public static class UIBlocker
{
// Палец сейчас занят миром (например, игрок тащит призрак здания в режиме стройки) --
// камера в это время не двигается. Ставит и снимает тот, кто захватил ввод.
public static bool WorldInputCaptured;
public static bool IsPointerOverUI()
{
if (EventSystem.current == null) return false;
@@ -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,73 +32,21 @@ 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; } // сколько жителей могут работать: все жители (общего ограничения нет, регламент 31.10)
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
int Soldiers { get; } // воины -- их хранит модуль Армии
int HousingCap { get; } // предел населения: выше него прирост не идёт
float FoodPerHour { get; } // сколько город съедает в час
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
bool IsStarving { get; } // еды не хватило на последний расчёт
// Голодал ли город в момент unixTime (секунды). Для боёв, прошедших, пока игра была закрыта:
// история хранится за последние MaxOfflineHours (48 ч), точность -- 10 минут (шаг расчёта).
bool WasStarvingAt(long unixTime);
// Найм воинов (решение команды от 19.10.2026): воины берутся из свободных жителей, гражданский резерв не отдаётся.
int CivilianReserve { get; } // гражданский резерв: столько свободных жителей в войско не взять
int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина
int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах
bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет
void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия (без проверки Армии)
// Обучение закончено -- передать новобранцев в Армию одним действием: люди уходят из Населения, только если
// Армия их действительно приняла (IArmy.AddTroops). false -- ничего не изменилось: столько новобранцев нет,
// Армия не подключена или не приняла (новобранцы остаются в Казармах, никто не теряется и не дублируется).
bool CompleteTraining(int troopType, int soldiers);
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
void Tick(long unixNow);
// Рабочие по профессиям (этап 3)
int GetWorkers(WorkJob job);
// Поставить count рабочих на профессию (в пределах мест в зданиях и свободных рабочих). Возвращает, сколько стоит теперь.
int SetWorkers(WorkJob job, int count);
}
// ---------------- Армия: войска ----------------
public interface IArmy
{
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
int GetTroops(int type); // воинов этого типа в городе
int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном)
int SoldiersAtHome { get; } // всего воинов в городе
int TroopsInMarches { get; } // воинов в походах
// Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется.
int GarrisonTotal { get; }
int GetGarrison(int type);
int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон
int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено
void AddTroops(int type, int count); // обучены / вернулись
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
// Забрать свободных воинов в поход (запоминает состав похода).
// false -- ничего не взято: не хватает воинов или поход с таким номером уже есть.
// Забрать воинов из города в поход (запоминает состав похода)
bool TakeForMarch(int marchId, int count);
// Вернуть выживших из похода по тем же типам войск.
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
bool ReturnFromMarch(int marchId, int survivors);
// Состав похода по типам (копия); null -- такого похода нет
int[] GetMarchUnits(int marchId);
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
bool ReturnUnits(int marchId, int[] survivorUnits);
// Вернуть выживших из похода по тем же типам войск
void ReturnFromMarch(int marchId, int survivors);
}
// ---------------- Глобальный мир: карта и перемещения ----------------
@@ -123,94 +57,23 @@ public interface IWorld
}
// ---------------- Боевая система: урон, потери, исход ----------------
// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии
public static class TroopType
{
public const int Infantry = 0; // пехота
public const int Archers = 1; // лучники
public const int Cavalry = 2; // кавалерия
public const int Siege = 3; // осадные
public const int Count = 4;
}
// Состояние войск стороны в бою (план от 27.10.2026). Штрафы применяются последовательно:
// голод (-50%) и усталость (-20%) вместе оставляют 40% силы, а не 30%. Числа -- в правилах боя.
[Flags]
public enum BattleCondition
{
None = 0,
Famine = 1, // голод в поселении на момент боя
Fatigue = 2 // войско недавно сражалось (пока не выставляется: правила учёта усталости не утверждены)
}
public struct BattleRequest
{
// Состав сторон по типам войск (TroopType). Если состав не задан (null) --
// берётся число Attackers / Defenders, и все считаются пехотой.
public int[] AttackerUnits;
public int[] DefenderUnits;
public int Attackers;
public int Defenders;
public bool DefenderFortified; // защитники за стенами (город, замок)
public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее
public int DefenderBonusPercent;
// Состояния сторон: голод, усталость. Действуют последовательно (умножаются), см. BattleCondition
public BattleCondition AttackerConditions;
public BattleCondition DefenderConditions;
// Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками --
// всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер.
public long Seed;
}
// Итог боя -- одинаково полный для обеих сторон. Все массивы -- по типам войск (TroopType).
// По каждому типу: до боя = выжившие + погибшие + раненые.
public struct BattleOutcome
{
public bool Victory; // победил атакующий
public long BattleId; // = Seed запроса
public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог
public int AttackerLosses; // выбыли из строя: погибшие + раненые
public int AttackerLosses;
public int DefenderLosses;
public int Survivors; // сколько атакующих осталось в строю
public long AttackerPower; // итоговая сила сторон x100: с контрами, стенами, бонусами и случайностью
public long DefenderPower;
public int AttackerLuckPermille; // случайность стороны в десятых долях процента: +23 = +2,3%
public int DefenderLuckPermille;
public int AttackerConditionPercent; // сколько силы оставили состояния стороны: 100 -- без штрафов, 50 -- голод
public int DefenderConditionPercent;
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
public int[] AttackerDeadUnits, DefenderDeadUnits;
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // пока нули: доля раненых не утверждена командой
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
}
// Прогноз до боя: с учётом случайности, без раскрытия итога (план от 27.10.2026)
public enum BattleChance
{
Advantage, // победа при любой случайности
Uncertain, // исход зависит от случайности
HighRisk // поражение при любой случайности
}
public struct BattleForecast
{
public BattleChance Chance;
public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности
public long DefenderPowerMin, DefenderPowerMax;
public int AttackerLossesMin, AttackerLossesMax;
public int Survivors; // сколько атакующих осталось
}
public interface IBattleSystem
{
BattleOutcome Resolve(BattleRequest request);
BattleForecast Forecast(BattleRequest request); // Seed не нужен: перебираются крайние значения случайности
}
// ---------------- Чат: общий мир и Круг ----------------
@@ -24,44 +24,12 @@ 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
{
public int AtHome;
public int InMarches;
public int Garrison; // из AtHome -- в гарнизоне
}
// ---------------- Глобальный мир ----------------
@@ -87,7 +55,6 @@ public struct MarchReturnedEvent
public bool Victory;
public int Survivors;
public int Sent; // сколько воинов уходило в поход
public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
}
@@ -118,8 +85,6 @@ public struct CircleLeftEvent
// ---------------- Сохранения ----------------
public struct GameLoadedEvent { }
// Файла сохранения нет -- это первая игра на устройстве (стартовые ресурсы, стартовое войско)
public struct NewGameStartedEvent { }
public struct GameSavedEvent { }
// Сохранения сброшены, все модули уже в состоянии новой игры
public struct GameResetEvent { }
-42
View File
@@ -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:
-53
View File
@@ -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,169 +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...). Не утверждено
// Голод (регламент 31.10, ТЕСТ): добыча всех ресурсов, кроме еды, ×0.5. Еда -- без штрафа.
// Штраф снимается сразу, как только город снова сыт (модуль Населения сообщает IsStarving).
public double FamineNonFoodMultiplier = 0.5;
}
// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md
// Без Unity: правила те же, что будут на сервере.
//
// Ресурс производят люди, здание даёт только места и эффективность:
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий,
// а при голоде всё, кроме еды, -- ещё × FamineNonFoodMultiplier (голод считает модуль Населения, IPopulation.IsStarving).
// Какие здания стоят в городе, сообщает слой приложения (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]);
double rate = working * rules.PerWorkerPerHour[j] * efficiency[j];
if (population.IsStarving && WorkJobs.Resource(job) != ResourceType.Food)
rate *= Math.Max(0, Math.Min(1, rules.FamineNonFoodMultiplier));
return rate;
}
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:
-8
View File
@@ -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,35 +0,0 @@
// МОДУЛЬ НАСЕЛЕНИЯ. Документ: Docs/Modules/Population.md
// Правила населения -- только числа, без 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; // каждый уровень Дома (здание "Население")
// Найм воинов: воины набираются из свободных (не работающих) жителей, гражданский резерв не отдаётся.
// Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1-2 -- 10, 3-5 -- 20, 6-10 -- 30.
// Элемент [i] -- резерв на уровне Ратуши i+1; выше последнего уровня -- последнее число.
public int[] CivilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
public int PeoplePerSoldier = 1; // сколько жителей стоит один воин (EconomyConfig, раздел "Солдат")
// Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено.
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,522 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
// МОДУЛЬ НАСЕЛЕНИЯ: сколько в городе людей, кто где работает, сколько едят и как прирастают.
// Документ: Docs/Modules/Population.md. Без Unity: правила те же, что будут на сервере.
//
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
// Жители: работать может любой житель (общего ограничения нет, регламент 31.10) -- рабочих ограничивают
// свободные жители и рабочие места в зданиях. Стартовый город: 120 рабочих из 270 жителей.
//
// Каждый человек ровно в одной группе (регламент 31.10):
// свободный житель = Civilians - Workers (Idle)
// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных)
// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает)
// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию)
// Total = Civilians + InTraining + Soldiers -- сумма всех групп.
// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок.
// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять.
//
// Время -- только из 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;
int inTraining; // люди, взятые из жителей в Казармы и ещё не обученные
// История голода: отрезки [начало, конец) в Unix-секундах, конец 0 -- голод идёт сейчас.
// Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод считается на момент боя.
readonly List<long[]> famine = new List<long[]>();
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);
// Работать может любой житель: общего ограничения нет (регламент 31.10).
// Рабочих ограничивают свободные жители и рабочие места (IProduction.Slots).
public int Workforce => civilians;
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 + inTraining + Soldiers;
// ---------------- Найм воинов ----------------
public int InTraining => inTraining;
public int CivilianReserve
{
get
{
int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1;
int[] table = rules.CivilianReserveByTownHallLevel;
if (table == null || table.Length == 0) return 0;
return Math.Max(0, table[Math.Min(level, table.Length) - 1]);
}
}
// Берём только свободных (Idle -- не на работе): рабочих сначала надо снять с профессии
public int Recruitable => Math.Max(0, Idle - CivilianReserve) / Math.Max(1, rules.PeoplePerSoldier);
public bool TryRecruit(int soldiers)
{
if (soldiers <= 0 || soldiers > Recruitable) return false;
int people = soldiers * Math.Max(1, rules.PeoplePerSoldier);
civilians -= people;
inTraining += people;
ClampJobs(); // жителей меньше -- рабочих тоже может стать меньше
Changed();
return true;
}
public bool CompleteTraining(int troopType, int soldiers)
{
if (soldiers <= 0) return false;
int people = soldiers * Math.Max(1, rules.PeoplePerSoldier);
if (people > inTraining) return false; // столько новобранцев нет (повтор или ошибка)
if (!GameServices.TryGet(out IArmy army)) return false;
// Сначала убираем из Казарм, потом отдаём в Армию: событие Армии увидит уже правильную сумму.
// Армия не приняла -- возвращаем новобранцев, как было.
inTraining -= people;
int before = army.SoldiersAtHome;
army.AddTroops(troopType, soldiers);
if (army.SoldiersAtHome - before != soldiers)
{
inTraining += people;
return false;
}
Changed();
return true;
}
public void RecruitsTrained(int soldiers)
{
if (soldiers <= 0) return;
// Заказы, начатые до найма из жителей (старое сохранение), людей не брали -- уходить в минус нельзя
inTraining = Math.Max(0, inTraining - soldiers * Math.Max(1, rules.PeoplePerSoldier));
Changed();
}
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;
public bool WasStarvingAt(long unixTime)
{
if (lastTickUnix == 0 || unixTime >= lastTickUnix) return starving; // ещё не посчитано -- как сейчас
foreach (var f in famine)
if (unixTime >= f[0] && (f[1] == 0 || unixTime < f[1])) return true;
return false;
}
// Голод начался или кончился в момент unix (вызывается из расчёта на каждом шаге)
void RecordFamine(bool nowStarving, long unix)
{
long[] open = famine.Count > 0 && famine[famine.Count - 1][1] == 0 ? famine[famine.Count - 1] : null;
if (nowStarving && open == null) famine.Add(new[] { unix, 0L });
else if (!nowStarving && open != null) open[1] = unix;
}
// Отрезки, закончившиеся раньше, чем 48 часов назад, Бою уже не понадобятся
void PruneFamine(long unixNow)
{
long keepFrom = unixNow - (long)rules.MaxOfflineHours * SecondsPerHour;
famine.RemoveAll(f => f[1] != 0 && f[1] <= keepFrom);
}
double FoodPerHourExact(int soldiers)
{
// Обучающиеся в Казармах едят как воины
return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * 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(unixNow - elapsed, elapsed);
lastTickUnix = unixNow;
PruneFamine(unixNow);
}
// ---------------- Расчёт ----------------
void Simulate(long startUnix, 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;
long stepStart = startUnix;
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;
RecordFamine(starving, stepStart); // голод начался (кончился) в этом куске -- запоминаем его начало
stepStart += step;
// 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда.
if (fed)
{
int room = HousingCap - (civilians + inTraining + 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();
}
// ---------------- Рабочие: расстановка ----------------
// Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10).
// Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false).
// Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки
// и улучшения остаются пустыми, пока игрок сам не поставит рабочих.
public void AutoFillAll()
{
bool changed = false;
bool progress = true;
int cap = Math.Max(Workers, rules.StartWorkers);
while (FreeWorkers > 0 && Workers < cap && progress)
{
progress = false;
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0 && Workers < cap; 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;
inTraining = 0;
famine.Clear();
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("training=").Append(inTraining.ToString(CultureInfo.InvariantCulture)).Append('\n');
// famine=начало-конец;начало-конец (конец 0 -- голод идёт сейчас)
var spans = new List<string>();
foreach (var f in famine)
spans.Add(f[0].ToString(CultureInfo.InvariantCulture) + "-" + f[1].ToString(CultureInfo.InvariantCulture));
sb.Append("famine=").Append(string.Join(";", spans.ToArray())).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 "training": inTraining = Math.Max(0, ParseInt(value, 0)); break;
case "famine":
foreach (var span in value.Split(';'))
{
string[] se = span.Split('-');
if (se.Length == 2
&& long.TryParse(se[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fs)
&& long.TryParse(se[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fe))
famine.Add(new[] { fs, fe });
}
break;
case "kit": startingKitGiven = value == "1"; break;
}
}
// Рабочих не может быть больше, чем жителей (испорченное или очень старое сохранение).
// Места в зданиях здесь не проверяем: секция Экономики может загрузиться позже -- это делает ClampJobs.
int over = Workers - civilians;
for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--)
{
int take = Math.Min(over, jobs[j]);
jobs[j] -= take;
over -= take;
}
}
// Новая игра: стартовый город. Стартовое войско выдаст 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
}
}
}
@@ -1,115 +0,0 @@
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Призрак здания в режиме стройки: полупрозрачная копия картинки здания и пятно его клеток под ней.
// Пятно зелёное -- сюда можно ставить, красное -- клетки заняты или за границей.
// Только картинка: скриптов и коллайдеров префаба у призрака нет, экономика его не видит.
public class BuildGhost : MonoBehaviour
{
public BuildingData Data { get; private set; }
public BuildFootprint Footprint { get; private set; }
public Vector2Int Cell { get; private set; } // левая нижняя клетка на сетке
public bool CellsFree { get; private set; }
public static readonly Color FreeColor = new Color(0.25f, 1f, 0.35f, 0.45f);
public static readonly Color BlockedColor = new Color(1f, 0.2f, 0.2f, 0.5f);
SpriteRenderer pad;
static Sprite white;
public static BuildGhost Create(BuildingData data)
{
var go = new GameObject("BuildGhost_" + data.buildingName);
var g = go.AddComponent<BuildGhost>();
g.Data = data;
g.Footprint = BuildFootprint.Of(data);
g.Build();
return g;
}
void Build()
{
// пятно клеток: белый квадрат 1×1, растянутый на размер здания
var padGo = new GameObject("Cells");
padGo.transform.SetParent(transform, false);
pad = padGo.AddComponent<SpriteRenderer>();
pad.sprite = WhiteSprite();
pad.sortingOrder = 3;
padGo.transform.localScale = new Vector3(Footprint.size.x, Footprint.size.y, 1f);
// копия картинки здания: позиция центра пятна = позиция префаба + anchorOffset
var art = new GameObject("Art").transform;
art.SetParent(transform, false);
art.localPosition = -(Vector3)Footprint.anchorOffset;
var prefab = Data.prefab;
if (prefab == null) return;
Vector3 root = prefab.transform.position;
int minOrder = int.MaxValue;
var renderers = prefab.GetComponentsInChildren<SpriteRenderer>(true);
foreach (var sr in renderers) if (sr.sprite != null) minOrder = Mathf.Min(minOrder, sr.sortingOrder);
foreach (var sr in renderers)
{
if (sr.sprite == null) continue;
var copy = new GameObject(sr.name).transform;
copy.SetParent(art, false);
copy.localPosition = sr.transform.position - root;
copy.localRotation = sr.transform.rotation;
copy.localScale = sr.transform.lossyScale;
var r = copy.gameObject.AddComponent<SpriteRenderer>();
r.sprite = sr.sprite;
r.flipX = sr.flipX;
r.flipY = sr.flipY;
r.drawMode = sr.drawMode;
if (sr.drawMode != SpriteDrawMode.Simple) r.size = sr.size;
var c = sr.color; c.a *= 0.65f;
r.color = c;
r.sortingOrder = 4 + (sr.sortingOrder - minOrder);
}
}
static Sprite WhiteSprite()
{
if (white != null) return white;
var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
var px = new Color32[16];
for (int i = 0; i < px.Length; i++) px[i] = new Color32(255, 255, 255, 255);
tex.SetPixels32(px);
tex.Apply();
white = Sprite.Create(tex, new Rect(0, 0, 4, 4), new Vector2(0.5f, 0.5f), 4);
return white;
}
// Поставить призрак на клетку (левая нижняя) и проверить, свободно ли
public void MoveToCell(Vector2Int cell)
{
var grid = BuildGrid.Instance;
Cell = cell;
Vector2 center = grid.RectCenter(cell, Footprint.size);
transform.position = new Vector3(center.x, center.y, 0f);
Recheck();
}
// Подвинуть так, чтобы центр пятна был ближе всего к точке мира
public void MoveNear(Vector2 worldCenter)
{
MoveToCell(BuildGrid.Instance.SnapCell(worldCenter, Footprint.size));
}
public void Recheck()
{
CellsFree = BuildGrid.Instance.CanPlace(Cell, Footprint.size);
if (pad != null) pad.color = CellsFree ? FreeColor : BlockedColor;
}
// Точка мира попадает в пятно призрака (с запасом под палец)
public bool Contains(Vector2 world, float margin = 0.4f)
{
Vector2 c = transform.position;
Vector2 half = (Vector2)Footprint.size * 0.5f + Vector2.one * margin;
return Mathf.Abs(world.x - c.x) <= half.x && Mathf.Abs(world.y - c.y) <= half.y;
}
// Куда ставить префаб, чтобы здание встало ровно на клетки призрака
public Vector3 PrefabPosition => transform.position - (Vector3)Footprint.anchorOffset;
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 4342ee63e1ed441786fda6c36b2da960
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,368 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Сетка свободной стройки: игрок сам выбирает, где поставить здание.
//
// - Клетка = 1 единица мира. Сетка прямоугольная (арт города не изометрический).
// - Где можно строить: прямоугольник вокруг старых участков (BuildSlot) и Ратуши с запасом padding.
// Можно задать руками: autoArea = false и areaMin / areaMax.
// - Занятые клетки: под каждым построенным зданием (его размер на сетке, BuildingData.footprint) и под Ратушей.
// Старые сохранения: здание стоит, где стояло, занятость считается по тому, какие клетки оно накрывает.
// - В режиме стройки сетка рисуется поверх земли: свободные клетки -- светлые линии, занятые -- красные.
// - Старые участки и площадки «Застройка» прячутся при запуске (свободная стройка их заменила).
//
// Объект создаётся сам (BuildGrid.Instance), руками на сцену ставить не нужно.
// Если поставить руками -- можно настроить поля в инспекторе.
public class BuildGrid : MonoBehaviour
{
static BuildGrid instance;
public static BuildGrid Instance
{
get
{
if (instance == null)
{
instance = FindFirstObjectByType<BuildGrid>();
if (instance == null) instance = new GameObject("BuildGrid").AddComponent<BuildGrid>();
}
return instance;
}
}
[Header("Где можно строить")]
[Tooltip("Посчитать область самому: старые участки + Ратуша + запас")]
public bool autoArea = true;
[Tooltip("Запас вокруг старых участков, клеток")]
public int padding = 3;
public Vector2 areaMin = new Vector2(-34, -10);
public Vector2 areaMax = new Vector2(4, 12);
[Header("Вид сетки")]
public int overlaySortingOrder = 1;
public Color lineColor = new Color(1f, 1f, 1f, 0.28f);
public Color freeColor = new Color(0.4f, 1f, 0.5f, 0.06f);
public Color busyColor = new Color(1f, 0.15f, 0.15f, 0.38f);
[Tooltip("Спрятать старые участки и площадки «Застройка» при запуске")]
public bool hideLegacySlots = true;
public Vector2Int Origin { get; private set; } // мировые координаты левого нижнего угла клетки (0, 0)
public int Width { get; private set; }
public int Height { get; private set; }
bool[,] busy;
bool initialized;
float nextRefresh;
SpriteRenderer overlay;
Texture2D overlayTex;
string lastOverlayKey;
const int PixelsPerCell = 16;
void Awake()
{
if (instance != null && instance != this) { Destroy(this); return; }
instance = this;
}
void OnDestroy()
{
if (instance == this) instance = null;
if (overlayTex != null) Destroy(overlayTex);
}
void Update()
{
if (overlay != null && overlay.enabled && Time.unscaledTime >= nextRefresh) Refresh();
}
// ---------------- область ----------------
void EnsureInit()
{
if (initialized) return;
initialized = true;
Rect area = autoArea ? AutoArea() : Rect.MinMaxRect(areaMin.x, areaMin.y, areaMax.x, areaMax.y);
Origin = new Vector2Int(Mathf.FloorToInt(area.xMin), Mathf.FloorToInt(area.yMin));
Width = Mathf.Max(1, Mathf.CeilToInt(area.xMax) - Origin.x);
Height = Mathf.Max(1, Mathf.CeilToInt(area.yMax) - Origin.y);
busy = new bool[Width, Height];
if (hideLegacySlots) HideLegacySlots();
Debug.Log($"[СТРОЙКА] Сетка {Width}×{Height} клеток, левый нижний угол {Origin}");
}
Rect AutoArea()
{
bool any = false;
Bounds b = new Bounds();
foreach (var slot in FindObjectsByType<BuildSlot>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
Bounds sb = new Bounds(slot.transform.position, Vector3.one);
var col = slot.GetComponent<Collider2D>();
if (col != null && col.enabled && col.gameObject.activeInHierarchy) sb.Encapsulate(col.bounds);
if (!any) { b = sb; any = true; } else b.Encapsulate(sb);
}
if (TownHall.Instance != null)
{
Bounds tb = TownHallBounds();
if (!any) { b = tb; b.Expand(new Vector3(24, 16, 0)); any = true; }
else b.Encapsulate(tb);
}
if (!any) b = new Bounds(Vector3.zero, new Vector3(30, 20, 0));
b.Expand(new Vector3(padding * 2, padding * 2, 0));
return Rect.MinMaxRect(b.min.x, b.min.y, b.max.x, b.max.y);
}
static Bounds TownHallBounds()
{
var th = TownHall.Instance;
var col = th.GetComponent<Collider2D>();
if (col != null && col.enabled) return col.bounds;
var sr = th.GetComponentInChildren<SpriteRenderer>();
return sr != null ? sr.bounds : new Bounds(th.transform.position, new Vector3(3, 3, 0));
}
static void HideLegacySlots()
{
foreach (var slot in FindObjectsByType<BuildSlot>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
HideOn(slot.gameObject);
var parent = slot.transform.parent;
if (parent != null && parent.name.StartsWith("Застройка")) HideOn(parent.gameObject);
}
}
// Прячем только сам объект (картинку и коллайдер), не детей -- и сцену не меняем
static void HideOn(GameObject go)
{
foreach (var sr in go.GetComponents<SpriteRenderer>()) sr.enabled = false;
foreach (var c in go.GetComponents<Collider2D>()) c.enabled = false;
}
// ---------------- клетки ----------------
public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
public bool IsBusy(int x, int y) { EnsureInit(); return !InBounds(x, y) || busy[x, y]; }
// Клетка мира -> индекс сетки
public Vector2Int WorldToCell(Vector2 world)
{
EnsureInit();
return new Vector2Int(Mathf.FloorToInt(world.x) - Origin.x, Mathf.FloorToInt(world.y) - Origin.y);
}
// Центр прямоугольника клеток (x, y, w, h) в мире
public Vector2 RectCenter(Vector2Int cell, Vector2Int size)
{
EnsureInit();
return new Vector2(Origin.x + cell.x + size.x * 0.5f, Origin.y + cell.y + size.y * 0.5f);
}
// Левая нижняя клетка для здания размера size, чей центр ближе всего к точке мира
public Vector2Int SnapCell(Vector2 worldCenter, Vector2Int size)
{
EnsureInit();
return new Vector2Int(Mathf.RoundToInt(worldCenter.x - Origin.x - size.x * 0.5f),
Mathf.RoundToInt(worldCenter.y - Origin.y - size.y * 0.5f));
}
// Можно ли поставить: все клетки внутри области и свободны
public bool CanPlace(Vector2Int cell, Vector2Int size)
{
EnsureInit();
for (int x = cell.x; x < cell.x + size.x; x++)
for (int y = cell.y; y < cell.y + size.y; y++)
if (!InBounds(x, y) || busy[x, y]) return false;
return true;
}
// Ближайшее свободное место для здания размера size рядом с точкой (по спирали). false -- места нет
public bool FindFreeNear(Vector2 worldCenter, Vector2Int size, out Vector2Int cell)
{
Refresh();
Vector2Int start = SnapCell(worldCenter, size);
int maxR = Mathf.Max(Width, Height);
for (int r = 0; r <= maxR; r++)
{
for (int dx = -r; dx <= r; dx++)
for (int dy = -r; dy <= r; dy++)
{
if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; // только кольцо радиуса r
var c = new Vector2Int(start.x + dx, start.y + dy);
if (CanPlace(c, size)) { cell = c; return true; }
}
}
cell = start;
return false;
}
// Пересчитать занятые клетки по тому, что стоит в городе сейчас
public void Refresh()
{
EnsureInit();
nextRefresh = Time.unscaledTime + 0.3f;
System.Array.Clear(busy, 0, busy.Length);
if (TownHall.Instance != null)
{
Bounds tb = TownHallBounds();
MarkRect(new Rect(tb.min.x, tb.min.y, tb.size.x, tb.size.y));
}
var bm = BuildManager.Instance;
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре
BuildingData data = bm != null ? bm.FindData(id.buildingName) : null;
Rect r;
if (data != null)
{
var fp = BuildFootprint.Of(data);
Vector2 center = (Vector2)id.transform.position + fp.anchorOffset;
r = new Rect(center.x - fp.size.x * 0.5f, center.y - fp.size.y * 0.5f, fp.size.x, fp.size.y);
}
else
{
var col = id.GetComponent<Collider2D>();
if (col == null) continue;
r = new Rect(col.bounds.min.x, col.bounds.min.y, col.bounds.size.x, col.bounds.size.y);
}
MarkRect(r);
}
if (overlay != null && overlay.enabled) RedrawOverlay();
}
// Отметить все клетки, которые прямоугольник мира накрывает (касание краем не считается)
void MarkRect(Rect r)
{
const float eps = 0.05f;
int x0 = Mathf.FloorToInt(r.xMin + eps) - Origin.x, x1 = Mathf.CeilToInt(r.xMax - eps) - Origin.x;
int y0 = Mathf.FloorToInt(r.yMin + eps) - Origin.y, y1 = Mathf.CeilToInt(r.yMax - eps) - Origin.y;
for (int x = Mathf.Max(0, x0); x < Mathf.Min(Width, x1); x++)
for (int y = Mathf.Max(0, y0); y < Mathf.Min(Height, y1); y++)
busy[x, y] = true;
}
// ---------------- рисунок сетки ----------------
public void ShowOverlay(bool show)
{
EnsureInit();
if (show && overlay == null) CreateOverlay();
if (overlay == null) return;
overlay.enabled = show;
if (show) Refresh();
}
void CreateOverlay()
{
overlayTex = new Texture2D(Width * PixelsPerCell, Height * PixelsPerCell, TextureFormat.RGBA32, false);
overlayTex.filterMode = FilterMode.Point;
overlayTex.wrapMode = TextureWrapMode.Clamp;
var sprite = Sprite.Create(overlayTex, new Rect(0, 0, overlayTex.width, overlayTex.height), Vector2.zero, PixelsPerCell);
var go = new GameObject("BuildGridOverlay");
go.transform.SetParent(transform, false);
go.transform.position = new Vector3(Origin.x, Origin.y, 0f);
overlay = go.AddComponent<SpriteRenderer>();
overlay.sprite = sprite;
overlay.sortingOrder = overlaySortingOrder;
lastOverlayKey = null;
}
void RedrawOverlay()
{
// перерисовываем только если занятость поменялась
var key = new System.Text.StringBuilder(Width * Height);
for (int y = 0; y < Height; y++) for (int x = 0; x < Width; x++) key.Append(busy[x, y] ? '1' : '0');
string k = key.ToString();
if (k == lastOverlayKey) return;
lastOverlayKey = k;
int w = overlayTex.width, h = overlayTex.height;
var px = new Color32[w * h];
Color32 line = lineColor, free = freeColor, bad = busyColor;
for (int y = 0; y < h; y++)
{
int cy = y / PixelsPerCell, iy = y % PixelsPerCell;
for (int x = 0; x < w; x++)
{
int cx = x / PixelsPerCell, ix = x % PixelsPerCell;
bool edge = ix == 0 || iy == 0 || x == w - 1 || y == h - 1;
px[y * w + x] = busy[cx, cy] ? bad : (edge ? line : free);
if (busy[cx, cy] && edge) px[y * w + x] = new Color32(bad.r, bad.g, bad.b, (byte)Mathf.Min(255, bad.a + 50));
}
}
overlayTex.SetPixels32(px);
overlayTex.Apply(false);
}
}
// Размер здания на сетке и где его "пятно" относительно точки, в которую ставится префаб.
// size -- BuildingData.footprint, а если там 0 × 0 -- коллайдер префаба (иначе картинка), округлённый до клеток.
// anchorOffset -- центр пятна минус позиция префаба (у многих картинок точка привязки не в центре).
public struct BuildFootprint
{
public Vector2Int size;
public Vector2 anchorOffset;
static readonly Dictionary<BuildingData, BuildFootprint> cache = new Dictionary<BuildingData, BuildFootprint>();
public static BuildFootprint Of(BuildingData data)
{
if (data == null) return new BuildFootprint { size = Vector2Int.one };
if (cache.TryGetValue(data, out var fp)) return fp;
Rect r = LocalRect(data.prefab);
fp.anchorOffset = r.center;
if (data.footprint.x > 0 && data.footprint.y > 0) fp.size = data.footprint;
else fp.size = new Vector2Int(Mathf.Max(1, Mathf.RoundToInt(r.width)), Mathf.Max(1, Mathf.RoundToInt(r.height)));
cache[data] = fp;
return fp;
}
// Прямоугольник префаба относительно его позиции, в единицах мира.
// Считаем по трансформам руками: у выключенного шаблона collider.bounds не работает.
static Rect LocalRect(GameObject prefab)
{
if (prefab == null) return new Rect(-0.5f, -0.5f, 1, 1);
Vector3 root = prefab.transform.position;
var box = prefab.GetComponent<BoxCollider2D>();
if (box == null) box = prefab.GetComponentInChildren<BoxCollider2D>(true);
if (box != null)
{
Vector2 half = box.size * 0.5f;
return Corners(box.transform, box.offset - half, box.offset + half, root);
}
bool any = false;
Rect result = new Rect();
foreach (var sr in prefab.GetComponentsInChildren<SpriteRenderer>(true))
{
if (sr.sprite == null) continue;
var b = sr.sprite.bounds;
Rect one = Corners(sr.transform, b.min, b.max, root);
if (!any) { result = one; any = true; }
else result = Rect.MinMaxRect(Mathf.Min(result.xMin, one.xMin), Mathf.Min(result.yMin, one.yMin),
Mathf.Max(result.xMax, one.xMax), Mathf.Max(result.yMax, one.yMax));
}
return any ? result : new Rect(-0.5f, -0.5f, 1, 1);
}
static Rect Corners(Transform t, Vector2 min, Vector2 max, Vector3 root)
{
Vector3 a = t.TransformPoint(new Vector3(min.x, min.y)) - root;
Vector3 b = t.TransformPoint(new Vector3(max.x, max.y)) - root;
Vector3 c = t.TransformPoint(new Vector3(min.x, max.y)) - root;
Vector3 d = t.TransformPoint(new Vector3(max.x, min.y)) - root;
return Rect.MinMaxRect(Mathf.Min(a.x, b.x, c.x, d.x), Mathf.Min(a.y, b.y, c.y, d.y),
Mathf.Max(a.x, b.x, c.x, d.x), Mathf.Max(a.y, b.y, c.y, d.y));
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 1986efcb01c943548be8d0a24fbc56c9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -2,9 +2,10 @@ using UnityEngine;
using System.Collections.Generic;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Список зданий и сама постройка.
// Сейчас стройка свободная, на сетке: игрок сам выбирает место (BuildGrid, BuildGhost, BuildModeController),
// а здание ставит BuildAt. Участки (BuildSlot) остались для старых сцен и сохранений -- BuildGrid их прячет.
// Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО
// на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано.
// Наведение на пустой слот -> подсветка (можно строить).
// Наведение на занятый слот / мимо слотов -> подсветки нет, клик ничего не делает.
public class BuildManager : MonoBehaviour
{
@@ -147,47 +148,7 @@ public class BuildManager : MonoBehaviour
Debug.Log("[СТРОЙКА] Размещение отменено.");
}
// ===== Свободная стройка на сетке =====
// Данные здания по имени (имя хранится в PlacedBuildingId и в сохранении)
public BuildingData FindData(string buildingName)
{
if (allBuildings == null || string.IsNullOrEmpty(buildingName)) return null;
foreach (var b in allBuildings) if (b != null && b.buildingName == buildingName) return b;
return null;
}
// Можно ли поставить здание на клетки (левая нижняя cell): клетки свободны и ресурсов хватает
public bool CanBuildAt(BuildingData data, Vector2Int cell)
{
if (data == null || data.prefab == null) return false;
return BuildGrid.Instance.CanPlace(cell, BuildFootprint.Of(data).size) && data.CanAfford();
}
// Построить здание на клетках сетки. Возвращает здание или null (занято / мало ресурсов).
public GameObject BuildAt(BuildingData data, Vector2Int cell)
{
var grid = BuildGrid.Instance;
grid.Refresh(); // занятость на этот момент, а не 0.3 с назад
if (!CanBuildAt(data, cell)) return null;
data.PayCost();
var fp = BuildFootprint.Of(data);
Vector2 center = grid.RectCenter(cell, fp.size);
Vector3 pos = new Vector3(center.x - fp.anchorOffset.x, center.y - fp.anchorOffset.y, 0f);
GameObject built = Instantiate(data.prefab, pos, Quaternion.identity);
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
built.AddComponent<PlacedBuildingId>().buildingName = data.buildingName;
Debug.Log($"[СТРОЙКА] Построено: {data.buildingName}, клетки {cell} размер {fp.size}");
EventBus.Publish(new BuildingPlacedEvent { BuildingName = data.buildingName });
grid.Refresh();
return built;
}
// ===== Старая схема: тап по участку -> выбор здания (участки спрятаны, оставлено для совместимости) =====
// ===== Новая схема стройки: тап по участку -> выбор здания (BuildModeController) =====
// Построить здание сразу на указанном участке. true -- получилось.
public bool BuildOnSlot(BuildingData data, BuildSlot slot)
@@ -17,10 +17,6 @@ public class BuildingData : ScriptableObject
[Tooltip("Необходимый уровень ратуши")]
public int requiredTownHallLevel = 1;
[Header("Размер на сетке стройки")]
[Tooltip("Сколько клеток занимает здание (ширина × высота). 0 × 0 -- посчитать само по коллайдеру префаба")]
public Vector2Int footprint;
[Header("Стоимость")]
public ResourceCost[] cost;
+3 -16
View File
@@ -1,18 +1,13 @@
using System;
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Вешается на объект Ратуши, который уже стоит на карте.
// Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager.
// Тап по Ратуше открывает окно Ратуши (слой приложения подписан на Tapped), улучшение -- кнопкой в окне.
public class TownHall : MonoBehaviour
{
public static TownHall Instance { get; private set; }
// Тап по Ратуше: интерфейс откроет её окно (модуль про интерфейс не знает)
public static event Action<TownHall> Tapped;
[Header("Уровень")]
public int currentLevel = 1;
public int maxLevel = 10;
@@ -69,18 +64,10 @@ public class TownHall : MonoBehaviour
EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel });
Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!");
}
Vector3 downPos;
void OnMouseDown()
{
downPos = Input.mousePosition;
}
// Отпустил палец на Ратуше, почти не сдвинув (а не тащил камеру) -- окно Ратуши. Раньше тап сразу улучшал
void OnMouseUpAsButton()
{
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на Ратушу под ней
if ((Input.mousePosition - downPos).magnitude > 25f) return;
Tapped?.Invoke(this);
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней
Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение...");
Upgrade();
}
}
@@ -6,8 +6,6 @@
"UnityEditor.TestRunner",
"Bogatyri.Core",
"Bogatyri.Army",
"Bogatyri.Population",
"Bogatyri.Economy.Rules",
"Bogatyri.Battle",
"Bogatyri.World.Simulation",
"Bogatyri.Chat",
+1 -448
View File
@@ -117,464 +117,17 @@ public class ModuleTests
Assert.AreEqual(0, army.SoldiersAtHome + army.TroopsInMarches);
}
// Решение команды от 19.10.2026: один солдат -- в одном месте. 30 солдат, 10 в гарнизон -> 20 доступны, всего 30.
[Test]
public void ArmyService_Garrison_OneSoldierInOnePlace()
{
var army = new ArmyService();
army.AddTroops(0, 20); army.AddTroops(1, 10);
Assert.AreEqual(10, army.SetGarrison(10));
Assert.AreEqual(10, army.GarrisonTotal);
Assert.AreEqual(20, army.AvailableForMarch);
Assert.AreEqual(30, army.SoldiersAtHome, "всего воинов столько же");
Assert.AreEqual(7, army.GetGarrison(0), "гарнизон по составу: 2/3 пехоты");
Assert.AreEqual(3, army.GetGarrison(1), "и 1/3 лучников");
Assert.IsFalse(army.TakeForMarch(1, 21), "гарнизон в поход не уходит");
Assert.IsTrue(army.TakeForMarch(1, 20));
Assert.AreEqual(0, army.AvailableForMarch);
Assert.AreEqual(10, army.GarrisonTotal, "гарнизон остался в городе");
Assert.AreEqual(30, army.SoldiersAtHome + army.TroopsInMarches);
Assert.AreEqual(10, army.SetGarrison(99), "гарнизон не больше воинов в городе");
army.LoseFraction(0.5);
Assert.LessOrEqual(army.GarrisonTotal, army.SoldiersAtHome, "после потерь гарнизон не больше войск");
for (int i = 0; i < 4; i++) Assert.LessOrEqual(army.GetGarrison(i), army.GetTroops(i));
var loaded = new ArmyService();
loaded.Restore(army.Capture());
Assert.AreEqual(army.GarrisonTotal, loaded.GarrisonTotal, "гарнизон сохраняется");
loaded.Restore("troops=5,0,0,0\n"); // старое сохранение без строки гарнизона
Assert.AreEqual(0, loaded.GarrisonTotal);
Assert.AreEqual(5, loaded.AvailableForMarch);
}
// ---------------- Армия: безопасная отправка и возвращение (ревью 27.10.2026, п. 2.1-2.2) ----------------
[Test]
public void ArmyService_UnknownMarch_ReturnsNobody()
{
var army = new ArmyService();
army.AddTroops(0, 5);
Assert.IsFalse(army.ReturnFromMarch(42, 10), "неизвестный поход");
Assert.IsFalse(army.ReturnUnits(42, new[] { 3, 3, 3, 3 }), "неизвестный поход");
Assert.AreEqual(5, army.SoldiersAtHome, "из ниоткуда никто не появился");
}
[Test]
public void ArmyService_SameMarch_IsCreditedOnlyOnce()
{
var army = new ArmyService();
army.AddTroops(0, 10); army.AddTroops(1, 10);
Assert.IsTrue(army.TakeForMarch(1, 10));
Assert.IsTrue(army.ReturnUnits(1, army.GetMarchUnits(1)));
Assert.IsFalse(army.ReturnUnits(1, new[] { 5, 5, 0, 0 }), "повторное событие возвращения");
Assert.IsFalse(army.ReturnFromMarch(1, 10), "и по другому пути -- тоже нет");
Assert.AreEqual(20, army.SoldiersAtHome + army.TroopsInMarches);
Assert.IsTrue(army.TakeForMarch(2, 10));
Assert.IsTrue(army.ReturnFromMarch(2, 99), "вернулось больше, чем ушло");
Assert.AreEqual(20, army.SoldiersAtHome, "больше, чем ушло, не вернётся");
}
[Test]
public void ArmyService_TakeForMarch_SameIdTwice_IsRejected()
{
var army = new ArmyService();
army.AddTroops(0, 20);
Assert.IsTrue(army.TakeForMarch(7, 5));
Assert.IsFalse(army.TakeForMarch(7, 5), "второй поход с тем же номером затёр бы первый");
Assert.AreEqual(15, army.SoldiersAtHome);
Assert.AreEqual(5, army.TroopsInMarches);
}
[Test]
public void World_CancelMarch_OnlyBeforeBattle()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _);
Assert.IsTrue(world.CancelMarch(m.id));
Assert.AreEqual(0, world.Marches.Count);
MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _);
world.Advance(fought.arriveMs, new List<WorldEvent>()); // бой прошёл, войско идёт домой
Assert.IsFalse(world.CancelMarch(fought.id), "после боя поход не отменяется");
}
// ---------------- Боевая система ----------------
[Test]
public void Battle_MoreSoldiersWins_TieGoesToDefender()
{
var battle = new BattleSystem(BattleRules.NoRandom);
var battle = new BattleSystem();
Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory);
Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory);
Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors);
}
[Test]
public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry()
{
var battle = new BattleSystem(BattleRules.NoRandom);
// Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124
var archersVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } });
Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту");
var cavalryVsInfantry = battle.Resolve(new BattleRequest
{ AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } });
Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию");
}
[Test]
public void Battle_WallsHelpDefenders_SiegeBreaksWalls()
{
var battle = new BattleSystem(BattleRules.NoRandom);
var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
Assert.IsTrue(field.Victory);
Assert.IsFalse(walls.Victory, "те же войска за стенами устояли");
var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле");
}
[Test]
public void Battle_LossesPerType_NeverExceedUnits_AndSumUp()
{
var battle = new BattleSystem(BattleRules.NoRandom);
var rnd = new Random(7);
for (int run = 0; run < 500; run++)
{
var att = new int[4]; var def = new int[4];
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); }
var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 });
int survivors = 0, defLost = 0;
for (int i = 0; i < 4; i++)
{
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]));
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]));
survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.Survivors, survivors);
Assert.AreEqual(o.DefenderLosses, defLost);
Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors);
}
}
// Малые отряды, пустые стороны, ничья, предельные бонусы (ревью 27.10.2026, п. 2.4).
// Проверяем только то, что не зависит от будущей формулы потерь: исход и арифметику.
// Сколько теряет одиночка-проигравший -- решение команды (правило округления), тест его не фиксирует.
[Test]
public void Battle_EdgeCases_OutcomeAndArithmetic()
{
var b = new BattleSystem(BattleRules.NoRandom);
var cases = new[]
{
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 1 x 1
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }, // 1 x 10
new BattleRequest { AttackerUnits = new[] { 10, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 10 x 1
new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 0 x 0
new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 } }, // 0 x 5
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 5 x 0
new BattleRequest { AttackerUnits = new[] { 3, 2, 1, 1 }, DefenderUnits = new[] { 2, 2, 2, 1 }, DefenderFortified = true },
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -100 },
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -500, DefenderBonusPercent = -500 },
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 50, 50, 50, 50 }, AttackerBonusPercent = 100000 },
new BattleRequest { Attackers = 7, Defenders = 7 }, // старый бой числом
};
foreach (var r in cases)
{
BattleOutcome o = b.Resolve(r);
int[] att = r.AttackerUnits ?? new[] { r.Attackers, 0, 0, 0 };
int[] def = r.DefenderUnits ?? new[] { r.Defenders, 0, 0, 0 };
Assert.GreaterOrEqual(o.AttackerPower, 0);
Assert.GreaterOrEqual(o.DefenderPower, 0);
Assert.AreEqual(o.AttackerPower > o.DefenderPower, o.Victory, "побеждает большая сила, ничья -- защитнику");
int survivors = 0, defLost = 0;
for (int i = 0; i < 4; i++)
{
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]), "выжившие атакующего в пределах состава");
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]), "потери защитника в пределах состава");
survivors += o.AttackerSurvivorUnits[i];
defLost += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.Survivors, survivors);
Assert.AreEqual(o.DefenderLosses, defLost);
Assert.AreEqual(att[0] + att[1] + att[2] + att[3], o.Survivors + o.AttackerLosses, "выжившие + погибшие = ушедшие");
}
// Ничья 1 x 1 -- победа защитника; пустая сторона никого не теряет
Assert.IsFalse(b.Resolve(cases[0]).Victory);
Assert.IsFalse(b.Resolve(cases[3]).Victory);
Assert.AreEqual(0, b.Resolve(cases[3]).AttackerLosses + b.Resolve(cases[3]).DefenderLosses);
Assert.IsTrue(b.Resolve(cases[5]).Victory, "на пустую цель -- победа");
Assert.AreEqual(0, b.Resolve(cases[5]).AttackerLosses, "на пустую цель -- без потерь");
Assert.IsFalse(b.Resolve(cases[8]).Victory, "обе силы обнулились -- ничья, защитнику");
}
// ---------------- Боевой цикл (этап 2 плана от 27.10.2026) ----------------
static readonly BattleRequest SampleBattle = new BattleRequest
{ AttackerUnits = new[] { 12, 8, 6, 4 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true };
[Test]
public void Battle_Luck_IsReproducible_AndWithinFivePercent()
{
var b = new BattleSystem();
bool plus = false, minus = false, differs = false;
BattleOutcome first = default;
for (long seed = 1; seed <= 2000; seed++)
{
var r = SampleBattle; r.Seed = seed;
BattleOutcome o = b.Resolve(r);
BattleOutcome again = b.Resolve(r);
Assert.AreEqual(o.AttackerPower, again.AttackerPower, "тот же номер боя -- тот же итог");
Assert.AreEqual(o.Survivors, again.Survivors);
Assert.AreEqual(seed, o.BattleId);
Assert.That(o.AttackerLuckPermille, Is.InRange(-50, 50));
Assert.That(o.DefenderLuckPermille, Is.InRange(-50, 50));
plus |= o.AttackerLuckPermille > 0;
minus |= o.AttackerLuckPermille < 0;
if (seed == 1) first = o; else differs |= o.AttackerPower != first.AttackerPower;
}
Assert.IsTrue(plus && minus && differs, "случайность бывает в обе стороны и меняется от боя к бою");
var noLuck = new BattleSystem(BattleRules.NoRandom);
var req = SampleBattle; req.Seed = 777;
Assert.AreEqual(0, noLuck.Resolve(req).AttackerLuckPermille, "RandomPercent = 0 -- без случайности");
}
[Test]
public void Battle_Forecast_ShowsRisk_AndNeverContradictsRealBattle()
{
var b = new BattleSystem();
var strong = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } };
var weak = new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 50, 0, 0, 0 } };
var even = new BattleRequest { AttackerUnits = new[] { 20, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } };
Assert.AreEqual(BattleChance.Advantage, b.Forecast(strong).Chance);
Assert.AreEqual(BattleChance.HighRisk, b.Forecast(weak).Chance);
Assert.AreEqual(BattleChance.Uncertain, b.Forecast(even).Chance);
bool evenWon = false, evenLost = false;
foreach (var req in new[] { strong, weak, even, SampleBattle })
{
BattleForecast f = b.Forecast(req);
for (long seed = 1; seed <= 300; seed++)
{
var r = req; r.Seed = seed;
BattleOutcome o = b.Resolve(r);
if (f.Chance == BattleChance.Advantage) Assert.IsTrue(o.Victory, "преимущество -- победа при любой удаче");
if (f.Chance == BattleChance.HighRisk) Assert.IsFalse(o.Victory, "высокий риск -- поражение при любой удаче");
Assert.That(o.AttackerPower, Is.InRange(f.AttackerPowerMin, f.AttackerPowerMax));
Assert.That(o.DefenderPower, Is.InRange(f.DefenderPowerMin, f.DefenderPowerMax));
Assert.That(o.AttackerLosses, Is.InRange(f.AttackerLossesMin, f.AttackerLossesMax), "потери в пределах прогноза");
if (req.AttackerUnits == even.AttackerUnits) { evenWon |= o.Victory; evenLost |= !o.Victory; }
}
}
Assert.IsTrue(evenWon && evenLost, "равный бой решает удача: бывает и победа, и поражение");
}
[Test]
public void Battle_Outcome_IsSymmetric_AndAddsUpPerType()
{
var b = new BattleSystem();
var rnd = new Random(11);
for (int run = 0; run < 300; run++)
{
var att = new int[4]; var def = new int[4];
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 25); def[i] = rnd.Next(0, 25); }
BattleOutcome o = b.Resolve(new BattleRequest
{ AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0, Seed = run });
int attOut = 0, defOut = 0;
for (int i = 0; i < 4; i++)
{
Assert.AreEqual(att[i], o.AttackerUnitsBefore[i]);
Assert.AreEqual(def[i], o.DefenderUnitsBefore[i]);
Assert.AreEqual(att[i], o.AttackerSurvivorUnits[i] + o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i], "атакующий: до = выжившие + погибшие + раненые");
Assert.AreEqual(def[i], o.DefenderSurvivorUnits[i] + o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], "защитник: то же");
Assert.AreEqual(0, o.AttackerWoundedUnits[i] + o.DefenderWoundedUnits[i], "раненых пока нет: доля не утверждена");
Assert.GreaterOrEqual(o.AttackerSurvivorUnits[i], 0);
Assert.GreaterOrEqual(o.DefenderSurvivorUnits[i], 0);
Assert.AreEqual(o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], o.DefenderLossUnits[i]);
attOut += o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i];
defOut += o.DefenderLossUnits[i];
}
Assert.AreEqual(o.AttackerLosses, attOut);
Assert.AreEqual(o.DefenderLosses, defOut);
Assert.AreEqual(BattleRules.Default.Version, o.RulesVersion);
}
}
[Test]
public void Battle_Rules_ComeFromConfig()
{
var req = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true };
Assert.IsFalse(new BattleSystem(BattleRules.NoRandom).Resolve(req).Victory, "стены +20% держат");
var noWalls = BattleRules.NoRandom;
noWalls.WallsPercent = 0;
noWalls.Version = 2;
BattleOutcome o = new BattleSystem(noWalls).Resolve(req);
Assert.IsTrue(o.Victory, "без стен те же войска побеждают");
Assert.AreEqual(2, o.RulesVersion, "итог помнит версию правил");
}
// Голод -50%, усталость -20%, вместе -- 40% (план от 27.10.2026)
[Test]
public void Battle_Famine_HalvesPower_AndStacksWithFatigue()
{
var b = new BattleSystem(BattleRules.NoRandom);
Assert.AreEqual(100, b.ConditionPercent(BattleCondition.None));
Assert.AreEqual(50, b.ConditionPercent(BattleCondition.Famine));
Assert.AreEqual(80, b.ConditionPercent(BattleCondition.Fatigue));
Assert.AreEqual(40, b.ConditionPercent(BattleCondition.Famine | BattleCondition.Fatigue), "последовательно: 50% x 80%, а не 30%");
// 40 пехоты берут замок Змея Горыныча, голодные -- нет
var req = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true };
BattleOutcome fed = b.Resolve(req);
req.AttackerConditions = BattleCondition.Famine;
BattleOutcome hungry = b.Resolve(req);
Assert.IsTrue(fed.Victory);
Assert.IsFalse(hungry.Victory, "голодное войско слабее вдвое");
Assert.AreEqual(fed.AttackerPower / 2, hungry.AttackerPower);
Assert.AreEqual(fed.DefenderPower, hungry.DefenderPower, "голод нападающего не трогает защитника");
Assert.AreEqual(50, hungry.AttackerConditionPercent);
Assert.AreEqual(100, hungry.DefenderConditionPercent);
// Голод защитника работает так же
var defHungry = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true };
Assert.IsFalse(b.Resolve(defHungry).Victory);
defHungry.DefenderConditions = BattleCondition.Famine;
Assert.IsTrue(b.Resolve(defHungry).Victory, "голодный гарнизон не удержал стены");
// Прогноз учитывает голод
Assert.AreEqual(BattleChance.HighRisk, new BattleSystem().Forecast(req).Chance);
}
[Test]
public void World_FamineAtBattleTime_WeakensMarch()
{
MarchData Run(BattleCondition conditions)
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom));
world.AttackerConditions = _ => conditions;
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _);
world.Advance(m.arriveMs, new List<WorldEvent>());
return m;
}
MarchData fed = Run(BattleCondition.None), hungry = Run(BattleCondition.Famine);
Assert.IsTrue(fed.victory);
Assert.AreEqual(100, fed.attackerConditionPercent);
Assert.IsFalse(hungry.victory, "голод в момент боя -- поражение");
Assert.AreEqual(50, hungry.attackerConditionPercent);
Assert.AreEqual((int)BattleCondition.Famine, hungry.attackerConditions);
// Не задано, кто голодает (сервер ещё не подключён, старый код) -- без штрафов
var plain = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom));
MarchData p = plain.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _);
plain.Advance(p.arriveMs, new List<WorldEvent>());
Assert.AreEqual(100, p.attackerConditionPercent);
}
[Test]
public void World_MarchBattle_IsReproducible()
{
MarchData Run()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, 1000, out _);
m.units = new[] { 12, 8, 6, 4 };
world.Advance(m.arriveMs, new List<WorldEvent>());
return m;
}
MarchData a = Run(), b = Run();
Assert.AreNotEqual(0, a.battleSeed);
Assert.AreEqual(a.battleSeed, b.battleSeed, "тот же поход -- тот же номер боя");
Assert.AreEqual(a.attackerPower, b.attackerPower);
Assert.AreEqual(a.attackerLuck, b.attackerLuck);
Assert.AreEqual(a.survivors, b.survivors);
}
[Test]
public void World_GarrisonComposition_SumsToGarrison_AndIsStable()
{
foreach (var node in WorldMapLayout.CreateDefault())
{
int[] a = WorldMapLayout.GarrisonUnits(node);
int[] b = WorldMapLayout.GarrisonUnits(node);
int sum = 0;
for (int i = 0; i < 4; i++) { Assert.GreaterOrEqual(a[i], 0); Assert.AreEqual(a[i], b[i]); sum += a[i]; }
Assert.AreEqual(node.garrison, sum, node.displayName);
}
}
// ---------------- Глобальный мир: лагеря разбойников ----------------
[Test]
public void World_Camps_AreSpreadOut_FightInField_WithoutSiege()
{
var nodes = WorldMapLayout.CreateDefault();
int camps = 0;
foreach (var camp in nodes)
{
if (camp.type != MapNodeType.Camp) continue;
camps++;
Assert.IsFalse(WorldMapLayout.IsFortified(camp), "лагерь -- бой в поле");
Assert.AreEqual(0, WorldMapLayout.GarrisonUnits(camp)[TroopType.Siege], "у разбойников нет осадных");
Assert.That(camp.level, Is.InRange(1, WorldMapLayout.CampLevels));
foreach (var other in nodes)
{
if (other == camp) continue;
int min = other.type == MapNodeType.BossCastle ? 4500 : 2600;
Assert.GreaterOrEqual(WorldMapLayout.Distance(camp, other), min - 1, camp.id + " слишком близко к " + other.id);
}
}
Assert.AreEqual(WorldMapLayout.CampCount, camps);
}
[Test]
public void World_CampCleared_SecondMarchFindsItEmpty_ThenItRespawns()
{
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
const string camp = "camp_1"; // 1 уровень, 6..9 разбойников
MarchData first = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 0, out string e1);
MarchData second = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 1, out string e2);
Assert.IsNotNull(first, e1);
Assert.IsNotNull(second, e2);
world.Advance(10 * 60_000, new List<WorldEvent>()); // через 10 минут оба похода закончились
Assert.IsTrue(first.victory);
Assert.IsNotEmpty(first.loot);
Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю");
Assert.AreEqual(30, second.survivors);
Assert.IsEmpty(second.loot);
Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000));
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется");
// Сохранение: другой мир с теми же данными помнит разбитый лагерь
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
loaded.LoadCampStates(world.GetCampStates());
Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000));
long respawn = first.arriveMs + CampRules.RespawnMs;
Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова");
Assert.IsNotNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, respawn, out _));
}
// ---------------- Глобальный мир: поход до конца, в том числе офлайн ----------------
[Test]
@@ -1,565 +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(270, p.Workforce, "работать может любой житель -- общего ограничения нет (регламент 31.10)");
Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)");
Assert.AreEqual(270, 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);
}
// ---------------- Найм воинов из жителей (решение команды от 19.10.2026) ----------------
[Test]
public void Recruit_150Free_Reserve30_Max120()
{
settlement.TownHallLevel = 6; // резерв 30 -- с 6 уровня Ратуши
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120); // 120 рабочих -- свободных 150
Assert.AreEqual(150, p.Idle);
Assert.AreEqual(30, p.CivilianReserve);
Assert.AreEqual(120, p.Recruitable, "пример команды: 150 свободных, резерв 30 -> 120");
Assert.IsFalse(p.TryRecruit(121));
Assert.IsTrue(p.TryRecruit(120));
Assert.AreEqual(0, p.Recruitable);
}
[Test]
public void Recruit_TakesCivilians_TotalStaysTheSame()
{
var p = StartCity();
Assert.IsTrue(p.TryRecruit(20));
Assert.AreEqual(250, p.Civilians);
Assert.AreEqual(20, p.InTraining);
Assert.AreEqual(300, p.Total, "житель стал новобранцем -- людей столько же");
p.RecruitsTrained(20);
army.AddTroops(0, 20); // Казармы выпускают воинов в Армию
Assert.AreEqual(0, p.InTraining);
Assert.AreEqual(50, p.Soldiers);
Assert.AreEqual(300, p.Total, "новобранцы не посчитаны дважды");
}
// ---------------- Группы людей не пересекаются (регламент 31.10) ----------------
static void AssertGroupsAddUp(PopulationService p, string when)
{
int free = p.Idle, workers = p.Workers, recruits = p.InTraining, soldiers = p.Soldiers;
Assert.AreEqual(p.Total, free + workers + recruits + soldiers, "свободные + рабочие + новобранцы + воины = все люди: " + when);
Assert.AreEqual(p.Civilians, free + workers, "жители = свободные + рабочие: " + when);
Assert.GreaterOrEqual(free, 0, when);
}
[Test]
public void Groups_NeverOverlap_SumEqualsTotal()
{
var p = StartCity();
AssertGroupsAddUp(p, "старт");
p.SetWorkers(WorkJob.Farmer, 100);
AssertGroupsAddUp(p, "поставили 100 рабочих");
Assert.IsTrue(p.TryRecruit(150)); // берутся только свободные: 170 - резерв 10 = 160 доступно
Assert.AreEqual(100, p.Workers, "рабочих в новобранцы не взяли");
AssertGroupsAddUp(p, "наняли 150");
p.RecruitsTrained(150);
army.AddTroops(0, 150);
AssertGroupsAddUp(p, "новобранцы стали воинами");
Assert.AreEqual(300, p.Total, "людей не прибавилось и не убавилось");
}
[Test]
public void Workers_CannotExceedFreeCivilians()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 100);
p.TryRecruit(150); // жителей осталось 120: 100 рабочих + 20 свободных
Assert.AreEqual(20, p.SetWorkers(WorkJob.Woodcutter, 50), "поставить можно только 20 свободных -- не воинов и не новобранцев");
Assert.AreEqual(0, p.FreeWorkers);
Assert.AreEqual(0, p.SetWorkers(WorkJob.Stonecutter, 10), "свободных нет -- никого");
AssertGroupsAddUp(p, "все жители на работе");
}
[Test]
public void Restore_WorkersNeverMoreThanCivilians()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120);
string save = p.Capture().Replace("civilians=270", "civilians=50"); // испорченное сохранение
var copy = new PopulationService(new PopulationRules());
copy.Restore(save);
Assert.AreEqual(50, copy.Workers, "рабочих не больше, чем жителей");
Assert.AreEqual(0, copy.Idle);
}
// ---------------- Интеграция: новобранцы переходят из Населения в Армию (настоящий ArmyService) ----------------
[Test]
public void Training_RecruitsMoveToArmy_NoOneLostOrDuplicated()
{
var p = StartCity();
int infantryBefore = army.GetTroops(0);
Assert.IsTrue(p.TryRecruit(20));
AssertGroupsAddUp(p, "новобранцы в Казармах");
Assert.IsTrue(p.CompleteTraining(0, 20));
Assert.AreEqual(0, p.InTraining);
Assert.AreEqual(infantryBefore + 20, army.GetTroops(0), "в Армии +20 пехоты");
Assert.AreEqual(300, p.Total, "людей столько же");
AssertGroupsAddUp(p, "обучение закончено");
}
[Test]
public void Training_ArmyRejects_RecruitsStayInBarracks()
{
var p = StartCity();
p.TryRecruit(20);
int soldiersBefore = p.Soldiers;
Assert.IsFalse(p.CompleteTraining(99, 20), "такого типа войск нет -- Армия не приняла");
Assert.AreEqual(20, p.InTraining, "новобранцы не пропали");
Assert.AreEqual(soldiersBefore, p.Soldiers, "и воинов из ничего не появилось");
Assert.AreEqual(300, p.Total);
AssertGroupsAddUp(p, "Армия не приняла");
}
[Test]
public void Training_RepeatedCompletion_DoesNotDuplicate()
{
var p = StartCity();
p.TryRecruit(20);
Assert.IsTrue(p.CompleteTraining(0, 20));
Assert.IsFalse(p.CompleteTraining(0, 20), "второй раз новобранцев уже нет");
Assert.AreEqual(50, p.Soldiers, "30 стартовых + 20, а не 70");
Assert.AreEqual(300, p.Total);
}
[Test]
public void Training_NoArmyModule_NothingChanges()
{
var p = StartCity();
p.TryRecruit(20);
GameServices.Unregister<IArmy>(army);
Assert.IsFalse(p.CompleteTraining(0, 20));
Assert.AreEqual(20, p.InTraining, "без Армии передавать некуда -- новобранцы ждут");
}
[Test]
public void Training_PartOfRecruits_OthersKeepTraining()
{
var p = StartCity();
p.TryRecruit(20);
Assert.IsTrue(p.CompleteTraining(1, 8)); // 8 лучников готовы
Assert.AreEqual(12, p.InTraining);
Assert.AreEqual(8, army.GetTroops(1));
Assert.AreEqual(300, p.Total);
AssertGroupsAddUp(p, "часть обучена");
}
[Test]
public void Reserve_DependsOnTownHallLevel_10_20_30()
{
var p = StartCity();
int[] expected = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30, 30 }; // 11 уровень -- как последний
for (int level = 1; level <= expected.Length; level++)
{
settlement.TownHallLevel = level;
Assert.AreEqual(expected[level - 1], p.CivilianReserve, "Ратуша " + level);
}
}
[Test]
public void Recruit_StartCity_Reserve10()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120);
Assert.AreEqual(10, p.CivilianReserve, "Ратуша 1 -- резерв 10");
Assert.AreEqual(140, p.Recruitable, "150 свободных - 10");
}
[Test]
public void Recruit_TakesOnlyFree_WorkersStayAtWork()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120);
p.TryRecruit(140);
Assert.AreEqual(120, p.Workers, "рабочие остались на работе");
Assert.AreEqual(10, p.Idle, "остался резерв");
Assert.AreEqual(0, p.Recruitable);
}
[Test]
public void AutoFillAll_NewGame_Places120Workers_RestStayFree()
{
var p = StartCity();
p.AutoFillAll(); // без модуля добычи мест не меньше -- ограничивает только старт: 120 рабочих
Assert.AreEqual(120, p.Workers);
Assert.AreEqual(150, p.Idle);
}
[Test]
public void Recruits_EatLikeSoldiers_AndAreSaved()
{
var p = StartCity();
p.TryRecruit(10);
Assert.AreEqual(260 * 0.1 + (30 + 10) * 0.2, p.FoodPerHour, 0.001);
var copy = new PopulationService(new PopulationRules());
copy.Restore(p.Capture());
Assert.AreEqual(10, copy.InTraining);
Assert.AreEqual(260, copy.Civilians);
copy.ResetState();
Assert.AreEqual(0, copy.InTraining);
}
[Test]
public void OldTrainingOrder_WithoutRecruits_DoesNotGoNegative()
{
var p = StartCity();
p.RecruitsTrained(5); // заказ из старого сохранения -- людей тогда не брали
Assert.AreEqual(0, p.InTraining);
Assert.AreEqual(270, p.Civilians);
}
// ---------------- История голода (вопрос Саши 7): бой, прошедший офлайн, знает голод на свой момент ----------------
[Test]
public void FamineHistory_OfflineBattleSeesHungerAtItsMoment()
{
economy.Set(ResourceType.Food, 50); // 33 еды в час -- хватит примерно на полтора часа
var p = StartCity();
p.Tick(T0 + 5 * Hour); // игра была закрыта 5 часов
Assert.IsFalse(p.WasStarvingAt(T0 + Hour), "через час еда ещё была");
Assert.IsTrue(p.WasStarvingAt(T0 + 2 * Hour), "через два часа -- голод");
Assert.IsTrue(p.WasStarvingAt(T0 + 5 * Hour), "и сейчас голод");
economy.Set(ResourceType.Food, 1000); // привезли еду
p.Tick(T0 + 8 * Hour);
Assert.IsFalse(p.IsStarving);
Assert.IsTrue(p.WasStarvingAt(T0 + 3 * Hour), "прошлый голод помнится");
Assert.IsFalse(p.WasStarvingAt(T0 + 7 * Hour), "после еды голода нет");
}
[Test]
public void FamineHistory_SurvivesSave_AndIsClearedByReset()
{
economy.Set(ResourceType.Food, 50);
var p = StartCity();
p.Tick(T0 + 5 * Hour);
economy.Set(ResourceType.Food, 1000);
p.Tick(T0 + 8 * Hour);
var copy = new PopulationService(new PopulationRules());
copy.Restore(p.Capture());
Assert.IsTrue(copy.WasStarvingAt(T0 + 3 * Hour));
Assert.IsFalse(copy.WasStarvingAt(T0 + Hour));
copy.ResetState();
Assert.IsFalse(copy.WasStarvingAt(T0 + 3 * Hour), "новая игра -- истории нет");
}
[Test]
public void FamineHistory_OlderThan48Hours_IsForgotten()
{
economy.Set(ResourceType.Food, 50);
var p = StartCity();
p.Tick(T0 + 5 * Hour);
economy.Set(ResourceType.Food, 10000);
p.Tick(T0 + 8 * Hour);
p.Tick(T0 + 60 * Hour);
Assert.IsFalse(p.WasStarvingAt(T0 + 3 * Hour), "голод 57 часов назад бою уже не нужен");
Assert.IsFalse(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,248 +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(170, population.FreeWorkers, "работать может любой из 270 жителей");
}
[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), "больше мест не поставить");
var farms = new List<KeyValuePair<WorkJob, int>> { new KeyValuePair<WorkJob, int>(WorkJob.Woodcutter, 1) };
for (int i = 0; i < 9; i++) farms.Add(new KeyValuePair<WorkJob, int>(WorkJob.Farmer, 1)); // 270 мест
production.SetBuildings(farms);
Assert.AreEqual(240, population.SetWorkers(WorkJob.Farmer, 270), "свободных жителей осталось 240 из 270");
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(180, population.FreeWorkers);
Assert.IsTrue(population.JobsInitialized);
}
[Test]
public void AutoFillAll_StartCity_NoMoreThan120Workers()
{
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Stonecutter, 1),
(WorkJob.MinerIron, 1), (WorkJob.MinerGold, 1))); // 150 мест
population.AutoFillAll();
Assert.AreEqual(120, population.Workers, "стартовый город: 120 рабочих");
Assert.AreEqual(24, population.GetWorkers(WorkJob.Woodcutter), "поровну по профессиям");
}
// ---------------- Голод (регламент 31.10, ТЕСТ): добыча кроме еды ×0.5 ----------------
[Test]
public void Famine_HalvesNonFoodProduction_FoodUnaffected_PenaltyEndsWhenFed()
{
economy.Set(ResourceType.Food, 0);
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Farmer, 5); // 10 еды в час -- город ест 33, голод
population.SetWorkers(WorkJob.Woodcutter, 30); // 60 дерева в час
population.Tick(T0 + Hour);
Assert.IsTrue(population.IsStarving);
Assert.AreEqual(30f, production.RatePerHour(ResourceType.Wood), 0.001f, "дерево вдвое меньше");
Assert.AreEqual(10f, production.RatePerHour(ResourceType.Food), 0.001f, "еда без штрафа");
economy.Set(ResourceType.Food, 1000); // привезли еду
population.Tick(T0 + 2 * Hour);
Assert.IsFalse(population.IsStarving);
Assert.AreEqual(60f, production.RatePerHour(ResourceType.Wood), 0.001f, "сыты -- штраф снят");
}
[Test]
public void NewBuilding_SlotsStayEmpty_UntilPlayerAssigns()
{
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
population.AutoFillAll(); // стартовая расстановка -- один раз
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); // построили вторую ферму
population.ClampJobs();
Assert.AreEqual(60, production.Slots(WorkJob.Farmer));
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer), "новые места пустые -- сами не заполняются");
Assert.AreEqual(60, population.SetWorkers(WorkJob.Farmer, 60), "игрок ставит сам");
}
[Test]
public void StartPlacement_HappensOnlyOnce()
{
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
population.AutoFillAll();
Assert.IsTrue(population.JobsInitialized);
population.SetWorkers(WorkJob.Farmer, 0); // игрок снял всех
// Сохранение и загрузка: расстановка уже была, повторно не нужна
var copy = new PopulationService(new PopulationRules());
copy.Restore(population.Capture());
Assert.IsTrue(copy.JobsInitialized, "второй раз CitySync расстановку не вызовет");
Assert.AreEqual(0, copy.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(240, 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:
+12 -58
View File
@@ -27,14 +27,12 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
public event Action<MarchData> OnBattleResolved;
public event Action<MarchData> OnMarchReturned;
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000;
// Время мира. В мультиплеере -- время сервера, а не телефона
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private readonly List<WorldEvent> events = new List<WorldEvent>();
private WorldSimulation world;
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
private List<CampState> pendingCamps;
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
public WorldSimulation World
@@ -44,11 +42,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
if (world == null)
{
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>());
world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditions() : BattleCondition.None;
if (pendingMarches != null) world.LoadMarches(pendingMarches);
if (pendingCamps != null) world.LoadCampStates(pendingCamps);
pendingMarches = null;
pendingCamps = null;
}
return world;
}
@@ -93,16 +88,6 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
}
// Состояние войск игрока сейчас: голод -- из модуля Населения (план от 27.10.2026: проверяется на момент боя).
// Бой, прошедший пока игра была закрыта, получает голод на момент входа: истории голода у Населения нет.
// Усталость пока не выставляется -- правила её учёта не утверждены.
public static BattleCondition PlayerConditions()
{
return GameServices.TryGet(out IPopulation population) && population.IsStarving
? BattleCondition.Famine
: BattleCondition.None;
}
// ---------------- Контракт IWorld ----------------
public int ActiveMarches => World.Marches.Count;
@@ -114,26 +99,17 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
error = "Нет данных о войске -- запусти игру из города";
return false;
}
if (army.AvailableForMarch < soldiers)
if (army.SoldiersAtHome < soldiers)
{
error = army.GarrisonTotal > 0
? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»"
: "Не хватает воинов. Наймите их в Казармах";
error = "Не хватает воинов. Наймите их в Казармах";
return false;
}
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
if (march == null) return false;
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
if (!army.TakeForMarch(march.id, soldiers))
{
World.CancelMarch(march.id);
error = "Не удалось собрать войско в поход";
return false;
}
march.units = army.GetMarchUnits(march.id); // состав похода -- для боя с типами войск
// Ушедшие воины недоступны дома, пока не вернутся
army.TakeForMarch(march.id, soldiers);
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
@@ -145,8 +121,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
void HandleBattleResolved(MarchData march)
{
MapNode target = World.GetNode(march.targetNodeId);
if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя");
else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
$"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}.");
OnBattleResolved?.Invoke(march);
EventBus.Publish(new MarchBattleResolvedEvent
@@ -162,17 +137,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
// Солдаты и добыча зачисляются, только когда войско дошло до дома
void HandleMarchReturned(MarchData march)
{
IArmy army = GameServices.Get<IArmy>();
bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count
? army.ReturnUnits(march.id, march.survivorUnits) // бой посчитал потери по типам
: army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции
// Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи
if (!returned)
{
Debug.LogWarning($"[ПОХОД] Поход {march.id} неизвестен Армии -- воины и добыча не зачислены");
return;
}
GameServices.Get<IArmy>().ReturnFromMarch(march.id, march.survivors);
IEconomy economy = GameServices.Get<IEconomy>();
foreach (var reward in march.loot)
@@ -191,7 +156,6 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
Victory = march.victory,
Survivors = march.survivors,
Sent = march.soldiers,
TargetEmpty = march.targetEmpty,
TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "",
LootText = FormatLoot(march.loot)
});
@@ -203,35 +167,25 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
class WorldSaveData
{
public List<MarchData> marches = new List<MarchData>();
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
}
public string SaveKey => "world";
public string Capture()
{
var camps = world != null ? world.GetCampStates() : new List<CampState>(pendingCamps ?? new List<CampState>());
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps });
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave() });
}
public void Restore(string json)
{
WorldSaveData data = JsonUtility.FromJson<WorldSaveData>(json);
LoadMarches(data?.marches);
if (world != null) world.LoadCampStates(data?.camps);
else pendingCamps = data?.camps;
LoadMarches(JsonUtility.FromJson<WorldSaveData>(json)?.marches);
}
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом)
public void ResetState()
{
pendingMarches = null;
pendingCamps = null;
if (world != null)
{
world.LoadMarches(new List<MarchData>());
world.LoadCampStates(null);
}
if (world != null) world.LoadMarches(new List<MarchData>());
}
public List<MarchData> GetMarchesForSave()
@@ -7,8 +7,7 @@ namespace Bogatyri.World
{
PlayerCity, // город игрока
NeighborCity, // соседний город -- другой игрок
BossCastle, // Большой замок с боссом в центре карты
Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова
BossCastle // Большой замок с боссом в центре карты
}
[Serializable]
@@ -32,7 +31,6 @@ namespace Bogatyri.World
public bool canBeAttacked;
public int garrison; // солдат в обороне
public int level; // уровень лагеря (1..3), у остальных точек 0
public List<RewardEntry> rewards = new List<RewardEntry>();
}
@@ -56,22 +54,6 @@ namespace Bogatyri.World
const int MinCitySpacing = 3600; // минимальное расстояние между городами
const int MaxPlacementAttempts = 1000;
// Лагеря разбойников (PvE). Ставятся отдельным генератором после городов -- карта городов не изменилась.
public const int CampCount = 12;
public const int CampLevels = 3;
const int CampSpacing = 2600; // лагерь может стоять ближе к городу, чем город к городу
const int CampSeedSalt = 0x5A17C0DE;
// Требует балансировки. Небольшие числа -- по концепции экономики.
static readonly string[] CampNames = { "Стан разбойников", "Логово лиходеев", "Становище Соловья-разбойника" };
static readonly int[] CampGarrison = { 6, 14, 26 }; // + 0..3 случайно
static readonly int[][] CampRewards = // еда, дерево, золото, металл
{
new[] { 15, 15, 0, 0 },
new[] { 25, 25, 8, 0 },
new[] { 40, 40, 15, 8 }
};
// Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков.
static readonly string[] NeighborNames =
{
@@ -146,45 +128,11 @@ namespace Bogatyri.World
nodes.Add(city);
}
AddCamps(nodes, new DeterministicRandom(seed ^ CampSeedSalt), CampCount);
return nodes;
}
static void AddCamps(List<MapNode> nodes, DeterministicRandom rng, int count)
{
string[] rewardTypes = { "Food", "Wood", "Gold", "Iron" };
for (int i = 0; i < count; i++)
{
if (!TryFindFreeSpot(rng, nodes, out int x, out int y, CampSpacing)) break;
int level = i % CampLevels + 1; // поровну лагерей каждого уровня
var camp = new MapNode
{
id = $"camp_{i + 1}",
displayName = CampNames[level - 1],
type = MapNodeType.Camp,
x = x,
y = y,
canBeAttacked = true,
level = level,
garrison = CampGarrison[level - 1] + rng.Range(0, 4)
};
int[] amounts = CampRewards[level - 1];
for (int r = 0; r < amounts.Length; r++)
if (amounts[r] > 0) camp.rewards.Add(new RewardEntry { resource = rewardTypes[r], amount = amounts[r] });
nodes.Add(camp);
}
}
// Защитники за стенами: замок и города. Лагерь -- бой в поле.
public static bool IsFortified(MapNode node)
{
return node != null && (node.type == MapNodeType.BossCastle || node.type == MapNodeType.NeighborCity);
}
// Случайная точка, не слишком близко к замку и к другим точкам
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y, int spacing = MinCitySpacing)
// Случайная точка, не слишком близко к замку и к другим городам
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y)
{
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
{
@@ -194,7 +142,7 @@ namespace Bogatyri.World
bool free = true;
foreach (var n in nodes)
{
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : spacing;
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : MinCitySpacing;
if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance)
{
free = false;
@@ -209,47 +157,6 @@ namespace Bogatyri.World
return false;
}
// Состав гарнизона по типам войск (TroopType). Вычисляется из id цели, а не из генератора карты:
// так карта (координаты, гарнизоны) осталась прежней, а состав всегда одинаков у клиента и сервера.
public static int[] GarrisonUnits(MapNode node)
{
var units = new int[TroopType.Count];
if (node == null || node.garrison <= 0) return units;
int[] percent;
if (node.type == MapNodeType.BossCastle)
{
percent = new[] { 40, 30, 20, 10 }; // Змей Горыныч держит всех понемногу
}
else
{
uint h = StableHash(node.id);
int infantry = 30 + (int)(h % 31); // 30..60%
int archers = 10 + (int)(h / 31 % 26); // 10..35%
int cavalry = 5 + (int)(h / 806 % 21); // 5..25%
int siege = Math.Max(0, 100 - infantry - archers - cavalry);
if (node.type == MapNodeType.Camp) { infantry += siege; siege = 0; } // у разбойников нет осадных машин
percent = new[] { infantry, archers, cavalry, siege };
}
int given = 0;
for (int i = 0; i < TroopType.Count; i++)
{
units[i] = node.garrison * percent[i] / 100;
given += units[i];
}
units[TroopType.Infantry] += node.garrison - given; // остаток округления -- пехоте
return units;
}
// FNV-1a: одинаков на любой платформе (string.GetHashCode в .NET меняется между запусками)
static uint StableHash(string s)
{
uint hash = 2166136261;
foreach (char c in s ?? "") hash = (hash ^ c) * 16777619;
return hash;
}
public static MapNode Find(List<MapNode> nodes, string id)
{
foreach (var n in nodes)
@@ -23,19 +23,8 @@ namespace Bogatyri.World
public int soldiers; // сколько ушло
public int survivors; // сколько возвращается (известно после боя)
public bool victory;
public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются
public int defenders; // гарнизон цели в момент боя
public int defenders; // гарнизон цели в момент боя
public int defenderLosses; // сколько потерял защитник
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
public int[] survivorUnits; // выжившие по типам (известно после боя)
public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется
public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента)
public long defenderPower;
public int attackerLuck;
public int defenderLuck;
public int attackerConditions; // BattleCondition нападающего в бою (голод, усталость)
public int attackerConditionPercent; // сколько силы оставили состояния: 100 -- без штрафов
public List<RewardEntry> loot = new List<RewardEntry>();
public long departMs;
@@ -56,20 +45,6 @@ namespace Bogatyri.World
}
}
public static class CampRules
{
// Разбитый лагерь появляется снова через 30 минут (требует балансировки)
public const long RespawnMs = 30L * 60 * 1000;
}
// Разбитый лагерь: до какого момента его нет на карте. Хранится в сохранении мира.
[Serializable]
public class CampState
{
public string id;
public long respawnMs;
}
public enum WorldEventType
{
BattleResolved, // войско дошло, бой прошёл
@@ -89,8 +64,6 @@ namespace Bogatyri.World
private readonly List<MapNode> nodes;
private readonly IBattleSystem battleSystem;
private readonly List<MarchData> marches = new List<MarchData>();
private readonly Dictionary<string, long> campRespawn = new Dictionary<string, long>(); // id лагеря -> когда появится
private readonly List<MarchData> arrivals = new List<MarchData>();
private int nextMarchId = 1;
public IReadOnlyList<MapNode> Nodes => nodes;
@@ -103,11 +76,6 @@ namespace Bogatyri.World
this.battleSystem = battleSystem;
}
// Состояние войска нападающего на момент боя (голод в его поселении, усталость).
// Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения,
// сервер -- по своим данным). Не задано -- без штрафов.
public Func<MarchData, BattleCondition> AttackerConditions { get; set; }
public MapNode GetNode(string id)
{
return WorldMapLayout.Find(nodes, id);
@@ -129,36 +97,6 @@ namespace Bogatyri.World
}
}
// ---------------- Лагеря ----------------
public bool IsCampCleared(string campId, long nowMs)
{
return campRespawn.TryGetValue(campId, out long at) && nowMs < at;
}
// Сколько миллисекунд до появления лагеря (0 -- лагерь на месте)
public long CampRespawnInMs(string campId, long nowMs)
{
return campRespawn.TryGetValue(campId, out long at) && nowMs < at ? at - nowMs : 0;
}
public List<CampState> GetCampStates()
{
var list = new List<CampState>();
foreach (var c in campRespawn) list.Add(new CampState { id = c.Key, respawnMs = c.Value });
return list;
}
public void LoadCampStates(List<CampState> saved)
{
campRespawn.Clear();
if (saved == null) return;
foreach (var c in saved)
if (c != null && GetNode(c.id) != null) campRespawn[c.id] = c.respawnMs;
}
// ---------------- Походы ----------------
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error)
{
MapNode from = GetNode(fromId);
@@ -166,11 +104,6 @@ namespace Bogatyri.World
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; }
if (target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs))
{
error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин";
return null;
}
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
var march = new MarchData
@@ -185,42 +118,24 @@ namespace Bogatyri.World
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
state = MarchState.Outbound
};
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
march.battleSeed = BattleSeed(march.id, nowMs, targetId);
marches.Add(march);
error = null;
return march;
}
// Откат только что созданного похода: войска для него не удалось зарезервировать.
// Отменить можно только поход, который ещё не дошёл до цели (боя не было).
public bool CancelMarch(int marchId)
{
MarchData m = marches.Find(x => x.id == marchId);
if (m == null || m.state != MarchState.Outbound) return false;
marches.Remove(m);
return true;
}
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
// может и дойти, и вернуться -- события придут в правильном порядке.
public void Advance(long nowMs, List<WorldEvent> events)
{
// Бои -- в порядке прихода войск: кто первым дошёл до лагеря, тот его и разбил
arrivals.Clear();
foreach (var m in marches)
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) arrivals.Add(m);
arrivals.Sort((a, b) => a.arriveMs != b.arriveMs ? a.arriveMs.CompareTo(b.arriveMs) : a.id.CompareTo(b.id));
foreach (var m in arrivals)
{
ResolveBattle(m);
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
}
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs)
{
ResolveBattle(m);
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
}
foreach (var m in marches)
{
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
{
m.state = MarchState.Finished;
@@ -229,89 +144,30 @@ namespace Bogatyri.World
}
marches.RemoveAll(m => m.state == MarchState.Finished);
// Лагеря, которые уже появились снова, больше не нужно помнить
expiredCamps.Clear();
foreach (var c in campRespawn) if (nowMs >= c.Value) expiredCamps.Add(c.Key);
foreach (var id in expiredCamps) campRespawn.Remove(id);
}
private readonly List<string> expiredCamps = new List<string>();
void ResolveBattle(MarchData m)
{
MapNode from = GetNode(m.fromNodeId);
MapNode target = GetNode(m.targetNodeId);
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
m.state = MarchState.Returning;
m.loot = new List<RewardEntry>();
// Лагерь уже разбит (другим войском): боя нет, все идут домой
if (target.type == MapNodeType.Camp && IsCampCleared(target.id, m.arriveMs))
{
m.targetEmpty = true;
m.victory = false;
m.survivors = m.soldiers;
m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null;
m.defenders = 0;
m.defenderLosses = 0;
return;
}
BattleCondition conditions = AttackerConditions != null ? AttackerConditions(m) : BattleCondition.None;
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest
{
AttackerUnits = HasUnits(m) ? m.units : null,
Attackers = m.soldiers,
DefenderUnits = WorldMapLayout.GarrisonUnits(target),
Defenders = target.garrison,
DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле
Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId), // старые походы без номера
AttackerConditions = conditions
});
m.attackerConditions = (int)conditions;
m.attackerConditionPercent = outcome.AttackerConditionPercent;
m.attackerPower = outcome.AttackerPower;
m.defenderPower = outcome.DefenderPower;
m.attackerLuck = outcome.AttackerLuckPermille;
m.defenderLuck = outcome.DefenderLuckPermille;
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { Attackers = m.soldiers, Defenders = target.garrison });
m.victory = outcome.Victory;
m.survivors = outcome.Survivors;
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
m.defenders = target.garrison;
m.defenderLosses = outcome.DefenderLosses;
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
// их потери будет списывать сервер из армии того игрока.
m.loot = new List<RewardEntry>();
if (outcome.Victory)
{
foreach (var r in target.rewards)
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
// Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя
if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs;
}
}
// Номер боя из данных похода (FNV-1a) -- одинаков на любой платформе
public static long BattleSeed(int marchId, long departMs, string targetId)
{
ulong h = 14695981039346656037UL;
void Mix(ulong v) { for (int i = 0; i < 8; i++) { h = (h ^ (v & 0xFF)) * 1099511628211UL; v >>= 8; } }
Mix((ulong)marchId);
Mix((ulong)departMs);
foreach (char c in targetId ?? "") Mix(c);
return (long)h;
}
// Состав похода известен и сходится с числом воинов (у старых походов его нет)
static bool HasUnits(MarchData m)
{
if (m.units == null || m.units.Length != TroopType.Count) return false;
int n = 0;
foreach (var u in m.units) n += u;
return n == m.soldiers && n > 0;
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
m.state = MarchState.Returning;
}
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
+1 -2
View File
@@ -32,8 +32,7 @@ public static class MarchReturnPopup
MarchReturnedEvent e = queue.Dequeue();
string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»";
string result = e.TargetEmpty ? "лагерь уже был разбит, боя не было"
: e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
string result = e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
int lost = Mathf.Max(0, e.Sent - e.Survivors);
string text = $"{target}: {result}\n" +
+5 -43
View File
@@ -2,19 +2,13 @@ using UnityEngine;
// Подгоняет RectTransform под безопасную зону экрана (Screen.safeArea): вырез камеры, скруглённые углы,
// полоска "домой" на iPhone. Следит за поворотом и сменой разрешения (и в Device Simulator).
// Вешать на растянутый объект; всё, что внутри, не залезет под вырез.
// - fitInsideParent = false: объект растянут на весь Canvas (HUD);
// - fitInsideParent = true: родитель не на весь экран (окно между верхней и нижней панелью) --
// зона пересчитывается в координаты родителя, отступ появляется только там, где родитель заходит под вырез.
// Вешать на растянутый на весь Canvas объект; всё, что внутри, не залезет под вырез.
[RequireComponent(typeof(RectTransform))]
public class SafeArea : MonoBehaviour
{
public bool fitInsideParent;
private RectTransform rect;
private Rect appliedArea;
private Vector2Int appliedScreen;
private Rect appliedParent;
void Awake()
{
@@ -24,8 +18,7 @@ public class SafeArea : MonoBehaviour
void Update()
{
if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y
|| (fitInsideParent && ParentScreenRect() != appliedParent))
if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y)
Apply();
}
@@ -36,41 +29,10 @@ public class SafeArea : MonoBehaviour
appliedScreen = new Vector2Int(Screen.width, Screen.height);
if (Screen.width <= 0 || Screen.height <= 0) return;
if (!fitInsideParent)
{
// Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком
rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height);
rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height);
}
else
{
Rect parent = ParentScreenRect();
appliedParent = parent;
if (parent.width <= 0 || parent.height <= 0) return;
// Пересечение родителя с безопасной зоной -- в долях родителя
float xMin = Mathf.Max(parent.xMin, area.xMin), xMax = Mathf.Min(parent.xMax, area.xMax);
float yMin = Mathf.Max(parent.yMin, area.yMin), yMax = Mathf.Min(parent.yMax, area.yMax);
if (xMax <= xMin || yMax <= yMin) return;
rect.anchorMin = new Vector2((xMin - parent.xMin) / parent.width, (yMin - parent.yMin) / parent.height);
rect.anchorMax = new Vector2((xMax - parent.xMin) / parent.width, (yMax - parent.yMin) / parent.height);
}
// Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком
rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height);
rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height);
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
}
// Прямоугольник родителя в пикселях экрана
Rect ParentScreenRect()
{
var parent = transform.parent as RectTransform;
if (parent == null) return new Rect(0, 0, Screen.width, Screen.height);
var corners = new Vector3[4];
parent.GetWorldCorners(corners);
Canvas canvas = parent.GetComponentInParent<Canvas>();
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null;
Vector2 min = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 max = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
return Rect.MinMaxRect(min.x, min.y, max.x, max.y);
}
}
+12 -160
View File
@@ -44,18 +44,15 @@ public class WorldMapController : MonoBehaviour
public TMP_Text popupBodyText;
public Button popupOkButton;
const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать";
const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать";
const float CastleSize = 2.2f;
const float CitySize = 1.1f;
const float CampSize = 0.8f;
const float StartZoom = 8f;
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f);
static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f);
static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f);
static readonly Color CampColor = new Color(0.45f, 0.2f, 0.45f);
static readonly Color CampClearedColor = new Color(0.35f, 0.35f, 0.35f, 0.6f);
private Transform mapRoot;
private readonly Dictionary<int, MarchView> marchViews = new Dictionary<int, MarchView>();
@@ -63,14 +60,6 @@ public class WorldMapController : MonoBehaviour
private readonly List<int> staleMarchIds = new List<int>();
private MapNode selectedTarget;
// Лагеря меняют вид: разбит -- серый, с таймером до появления
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
private float nextCampRefresh;
// Окно атаки: постоянная часть текста и состояние войска, с которым оно показано
private string attackInfoBase;
private BattleCondition shownConditions;
WorldSimulation World => MarchManager.Instance.World;
void Start()
@@ -118,13 +107,8 @@ public class WorldMapController : MonoBehaviour
{
HandleMapTap();
SyncMarchViews();
RefreshCamps();
RefreshOpenAttackPanel();
// Воины, а не отряды: "В походе: 1" читалось как один воин
int inMarches = 0, garrison = 0;
if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; }
soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {World.Marches.Count})";
soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}";
}
void HookUpComingSoon(Button button, string sectionName)
@@ -165,13 +149,6 @@ public class WorldMapController : MonoBehaviour
CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0);
CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f);
break;
case MapNodeType.Camp:
// Круг, а не квадрат: лагерь не путается с городом
var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0);
var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f);
campViews.Add((node, sprite, label));
break;
}
}
}
@@ -210,7 +187,7 @@ public class WorldMapController : MonoBehaviour
return sr;
}
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
void CreateLabel(string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(mapRoot, false);
@@ -221,29 +198,6 @@ public class WorldMapController : MonoBehaviour
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
tmp.sortingOrder = 10;
return tmp;
}
void RefreshCamps()
{
if (Time.unscaledTime < nextCampRefresh) return;
nextCampRefresh = Time.unscaledTime + 1f;
long now = MarchManager.NowMs;
foreach (var (node, sprite, label) in campViews)
{
long left = World.CampRespawnInMs(node.id, now);
sprite.color = left > 0 ? CampClearedColor : CampColor;
label.text = left > 0
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
: $"{node.displayName}, ур. {node.level}\nГарнизон: {node.garrison}";
}
}
static string FormatMinutes(long ms)
{
long sec = (ms + 999) / 1000;
return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек";
}
public static Vector3 ToWorld(MapNode node)
@@ -263,8 +217,6 @@ public class WorldMapController : MonoBehaviour
if (node == null) return;
if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него";
else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs))
hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}";
else if (node.canBeAttacked) OpenAttackPanel(node);
}
@@ -273,8 +225,7 @@ public class WorldMapController : MonoBehaviour
foreach (var node in World.Nodes)
{
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize;
float halfSize = size / 2f + 0.2f;
float halfSize = (node.type == MapNodeType.BossCastle ? CastleSize : CitySize) / 2f + 0.2f;
Vector3 pos = ToWorld(node);
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
}
@@ -290,17 +241,12 @@ public class WorldMapController : MonoBehaviour
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}"
: target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}"
attackTitleText.text = target.type == MapNodeType.NeighborCity
? $"Город игрока {target.displayName}"
: target.displayName;
attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает
attackInfoText.fontSizeMin = 18;
attackInfoText.fontSizeMax = 32;
attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" +
$"Путь в одну сторону: {travelSeconds} сек" +
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
RefreshAttackConditions();
attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" +
$"Путь в одну сторону: {travelSeconds} сек\n" +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
soldiersSlider.minValue = available > 0 ? 1 : 0;
soldiersSlider.maxValue = available;
@@ -313,90 +259,12 @@ public class WorldMapController : MonoBehaviour
attackPanel.SetActive(true);
}
// Голод может начаться или кончиться, пока окно атаки открыто: предупреждение и прогноз
// пересчитываются, как только состояние войска изменилось (проверка -- в Update)
void RefreshAttackConditions()
{
shownConditions = MarchManager.PlayerConditions();
attackInfoText.text = attackInfoBase + ConditionsWarning(shownConditions);
}
void RefreshOpenAttackPanel()
{
if (!attackPanel.activeSelf || selectedTarget == null) return;
if (MarchManager.PlayerConditions() == shownConditions) return;
RefreshAttackConditions();
UpdateSoldiersToSendText();
}
void UpdateSoldiersToSendText()
{
int available = GetSoldiersAtHome();
// Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась
soldiersToSendText.enableAutoSizing = true;
soldiersToSendText.fontSizeMin = 18;
soldiersToSendText.fontSizeMax = 32;
soldiersToSendText.overflowMode = TMPro.TextOverflowModes.Truncate;
var size = soldiersToSendText.rectTransform.sizeDelta;
if (size.y < 76) soldiersToSendText.rectTransform.sizeDelta = new Vector2(size.x, 76);
soldiersToSendText.text = available > 0
? $"Отправить: {(int)soldiersSlider.value} из {available}\n{PreviewText((int)soldiersSlider.value)}"
: GameServices.TryGet(out IArmy army) && army.GarrisonTotal > 0
? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)"
: "Дома нет солдат";
}
// Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия
string PreviewText(int soldiers)
{
if (selectedTarget == null || soldiers <= 0) return "";
IArmy army = GameServices.Get<IArmy>();
var troops = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i); // гарнизон в поход не идёт
int[] units = ArmyMath.TakeProportional(troops, soldiers);
if (units == null) return "";
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget),
AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз
});
string chance = f.Chance == BattleChance.Advantage ? "<color=#7CD67C>преимущество</color>"
: f.Chance == BattleChance.HighRisk ? "<color=#E06A5A>высокий риск поражения</color>"
: "<color=#E8C15A>исход неясен</color>";
return $"сила {Range(f.AttackerPowerMin, f.AttackerPowerMax)} против {Range(f.DefenderPowerMin, f.DefenderPowerMax)} -- " +
$"{chance}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}";
}
// Диапазон "120-131" (числа x100 -> обычные); одинаковые края -- одно число
static string Range(long min, long max)
{
long a = min / 100, b = max / 100;
return a == b ? a.ToString() : $"{a}-{b}";
}
// Предупреждение в окне атаки: голод ослабляет войско (план от 27.10.2026: -50%, проверяется на момент боя)
static string ConditionsWarning(BattleCondition conditions)
{
if ((conditions & BattleCondition.Famine) == 0) return "";
int percent = GameServices.Get<IBattleSystem>() is BattleSystem b ? b.ConditionPercent(conditions) : 50;
return $"\n<color=#E06A5A>Голод: войско слабее, сила {percent}%. Накормите город до боя</color>";
}
// Случайность стороны: +23 десятых процента -> "+2,3%"
static string Luck(int permille)
{
return (permille >= 0 ? "+" : "-") + (System.Math.Abs(permille) / 10f).ToString("0.#") + "%";
}
static string UnitsText(int[] units)
{
return $"пех. {units[TroopType.Infantry]}, луч. {units[TroopType.Archers]}, " +
$"кав. {units[TroopType.Cavalry]}, осад. {units[TroopType.Siege]}";
? $"Отправить: {(int)soldiersSlider.value} из {available}"
: "Дома нет солдат";
}
void CloseAttackPanel()
@@ -424,7 +292,7 @@ public class WorldMapController : MonoBehaviour
static int GetSoldiersAtHome()
{
return ResourceManager.Instance != null
? GameServices.Get<IArmy>().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город
? GameServices.Get<IArmy>().SoldiersAtHome // [объединение] наши воины всех типов
: 0;
}
@@ -467,21 +335,10 @@ public class WorldMapController : MonoBehaviour
void HandleBattleResolved(MarchData march)
{
MapNode target = World.GetNode(march.targetNodeId);
if (march.targetEmpty)
{
EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов");
return;
}
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
string body = $"{target.displayName}\n" +
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
$"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" +
(march.attackerPower > 0 || march.defenderPower > 0
? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " +
$"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" +
((march.attackerConditions & (int)BattleCondition.Famine) != 0
? $", <color=#E06A5A>голод: сила {march.attackerConditionPercent}%</color>" : "") + "\n"
: "") +
$"Домой идут: {march.survivors}\n";
body += march.victory
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
@@ -513,11 +370,6 @@ public class WorldMapController : MonoBehaviour
var (title, body) = popupQueue.Dequeue();
popupTitleText.text = title;
popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f);
// Итог боя длинный (строка силы и удачи, добыча) -- текст ужимается, а не уходит под кнопку OK
popupBodyText.enableAutoSizing = true;
popupBodyText.fontSizeMin = 16;
popupBodyText.fontSizeMax = 30;
popupBodyText.overflowMode = TMPro.TextOverflowModes.Truncate;
popupBodyText.text = body;
popupPanel.SetActive(true);
}
+7 -36
View File
@@ -1,6 +1,6 @@
# Армия
Войска и их характеристики: сколько воинов каждого типа в городе, сколько из них в гарнизоне, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
Войска и их характеристики: сколько воинов каждого типа в городе, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
- **Сборка:** `Bogatyri.Army` (без Unity).
- **Папка:** `Assets/Scripts/Modules/Army`.
@@ -12,17 +12,12 @@
| --- | --- |
| `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). |
| `GetTroops(type)` | Воинов этого типа в городе. |
| `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). |
| `SoldiersAtHome` | Всего воинов в городе. |
| `TroopsInMarches` | Воинов в походах. |
| `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. |
| `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. |
| `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. |
| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). |
| `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. |
| `TakeForMarch(marchId, count)` | Забрать свободных воинов (без гарнизона) в поход пропорционально составу и запомнить состав. `false` — ничего не взято: не хватает воинов или поход с таким номером уже есть. |
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. |
| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. |
| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. |
| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. |
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. |
Получить: `GameServices.Get<IArmy>()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт.
@@ -30,26 +25,9 @@
Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `ArmyService_MarchNeverCreatesOrLosesSoldiers` проверяет, что «дома + в походах» всегда равно общему числу.
**Возвращение — только по известному походу и только один раз** (ревью 27.10.2026, п. 2.1):
- неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого — раньше такие выжившие зачислялись пехотой;
- после возвращения запись похода удаляется, второе возвращение того же похода возвращает `false`;
- вернуться может не больше, чем ушло;
- два похода с одним номером невозможны: `TakeForMarch` отклоняет повтор, загрузка и перенос старых данных пропускают дубликаты;
- перенос старых сохранений — отдельно, в `ImportLegacy`.
**Гарнизон** — часть воинов города. Они по-прежнему в городе (`SoldiersAtHome`), но в поход не уходят:
- 30 воинов, 10 в гарнизоне → 20 свободны, всего по-прежнему 30 (тест `ArmyService_Garrison_OneSoldierInOnePlace`);
- гарнизон по каждому типу никогда не больше воинов этого типа: после потерь (`LoseFraction`) он обрезается;
- новые воины из Казарм становятся свободными, гарнизон не меняется;
- назначается в окне «Войска» в городе, карточка «Гарнизон города»: кнопки 0, -10, -1, +1, +10, «Все».
Пока гарнизон только держит воинов дома: на город игрока никто не нападает. Защита города, еда гарнизона и дебаффы при голоде — по плану ниже.
## События
- **Публикует:** `TroopsChangedEvent` (воинов в городе, из них в гарнизоне, и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD.
- **Публикует:** `TroopsChangedEvent` (воинов в городе и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD.
- **Слушает:** ничего.
## Сохранение
@@ -58,13 +36,10 @@
```
troops=<пехота>,<лучники>,<кавалерия>,<осадные>
garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops)
march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
```
Старое сохранение без строки `garrison` читается: гарнизона нет.
Сброс (`ResetState`): войск, гарнизона и походов нет.
Сброс (`ResetState`): войск и походов нет.
**Перенос из старых сохранений:**
- Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`.
@@ -87,17 +62,13 @@ march=<id похода>:<пехота>,<лучники>,<кавалерия>,<о
- общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**;
- гарнизон ест еду и участвует в защите;
- при голоде дебаффы действуют и на гарнизон, и на походы.
- **Сделано 27.10.2026:** гарнизон города (`SetGarrison`, `AvailableForMarch`).
- **Дальше по гарнизону:** участие в защите города (когда на него смогут напасть — PvP), еда гарнизона (сейчас все воины едят одинаково, считает модуль Населения), дебаффы при голоде. Выбор состава по типам — если понадобится.
- **Следующий шаг модуля:** гарнизон — третье место для солдата рядом с «дома» и «в походе».
- Описания типов войск (`GameData.Troops`: цена, сила, время обучения) пока в слое приложения; цены перейдут в `EconomyConfig`.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 28.10.2026 | Claude | По ревью 27.10.2026 (п. 2.1–2.2): `ReturnFromMarch` и `ReturnUnits` возвращают `bool` и зачисляют только по известному походу и только один раз; `TakeForMarch` отклоняет повторный номер похода; дубликаты походов в сохранении пропускаются. Тесты: неизвестный поход, повторное возвращение, повторный номер. |
| 27.10.2026 | Claude | Гарнизон города: `GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`; в поход уходят только свободные; строка `garrison=` в сохранении; поле `Garrison` в `TroopsChangedEvent`; карточка «Гарнизон города» в окне «Войска». Тест «один солдат в одном месте» с гарнизоном. |
| 27.10.2026 | Claude | `GetMarchUnits` и `ReturnUnits`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. |
| 23.10.2026 | Claude | Войска переехали в модуль: `ArmyService` — единственное хранилище войск (секция `"army"`), `IArmy` расширен (типы, обучение, потери, войска в походах), событие `TroopsChangedEvent`. Перенос из `progress.json` с удалением старых данных только после записи `save.json`. `ArmyBridge` удалён — контракты и перенос сохранений проверены тестами. |
| 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). |
| 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. |
+13 -96
View File
@@ -1,87 +1,28 @@
# Боевая система
Урон, потери, исход боя с учётом типов войск. Модуль только считает: кто победил и сколько погибло каждого типа. Кто идёт в бой и что делать с итогом, решают другие модули.
Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули.
- **Сборка:** `Bogatyri.Battle` (без Unity и без float — тот же код будет считать бои на сервере).
- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере).
- **Папка:** `Assets/Scripts/Modules/Battle`.
- **Зависит от:** ядра.
## Контракт `IBattleSystem`
- `BattleOutcome Resolve(BattleRequest request)` — бой.
- `BattleForecast Forecast(BattleRequest request)` — прогноз до боя с учётом случайности. Итог конкретного боя не раскрывает.
`BattleOutcome Resolve(BattleRequest request)`
| `BattleRequest` | Что это |
| `BattleRequest` | `BattleOutcome` |
| --- | --- |
| `AttackerUnits`, `DefenderUnits` | Состав сторон по типам (`TroopType`: пехота, лучники, кавалерия, осадные). |
| `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. |
| `DefenderFortified` | Защитники за стенами (город, замок). У лагерей разбойников стен нет — бой в поле. |
| `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). |
| `AttackerConditions`, `DefenderConditions` | Состояния сторон (`BattleCondition`, флаги): `Famine` — голод, `Fatigue` — усталость. |
| `Seed` | Номер боя. Из него считается случайность сторон: тот же номер с теми же войсками — всегда тот же итог. |
| `Attackers` — атакующих | `Victory` — победил атакующий |
| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` |
| `BattleOutcome` | Что это |
| --- | --- |
| `Victory` | Победил атакующий. |
| `BattleId`, `RulesVersion` | Номер боя (= `Seed`) и версия правил, по которой посчитан итог. |
| `AttackerPower`, `DefenderPower` | Итоговая сила сторон ×100 (целые числа): с контрами, стенами, бонусами и случайностью. |
| `AttackerLuckPermille`, `DefenderLuckPermille` | Выпавшая случайность сторон в десятых долях процента: +23 = +2,3%. |
| `AttackerConditionPercent`, `DefenderConditionPercent` | Сколько силы оставили состояния: 100 — без штрафов, 50 — голод, 40 — голод и усталость. |
| `AttackerLosses`, `DefenderLosses`, `Survivors` | Выбывшие (погибшие + раненые) и выжившие атакующие — всего. |
| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Раненых пока нет (нули): доля не утверждена. |
| `DefenderLossUnits` | Выбывшие защитники по типам — для старого кода. |
Регистрирует `App/GameModules`. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
| `BattleForecast` | Что это |
| --- | --- |
| `Chance` | `Advantage` — победа при любой случайности, `Uncertain` — решает случайность, `HighRisk` — поражение при любой случайности. |
| `AttackerPowerMin/Max`, `DefenderPowerMin/Max` | Сила сторон при крайних значениях случайности. |
| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего. |
## Текущие правила
Регистрирует `App/GameModules`: числа боя — из таблицы `BattleConfig`, сила воина каждого типа — из `GameData.Troops` (4 / 5 / 9 / 16). Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
- Побеждает тот, у кого больше солдат; ничья — победа защитника.
- Проигравший теряет половину своего войска, победитель — столько же.
## Правила
Числа — **стартовые для тестирования** (план от 27.10.2026). Все они в одном месте:
- **в игре** — таблица `Assets/Resources/BattleConfig.asset`: выдели её в окне Project и меняй числа в Inspector, изменения действуют со следующего запуска. Скрипт таблицы — `App/BattleConfig.cs`;
- **в модуле** — `BattleRules.cs` (значения по умолчанию для тестов и сервера). `App/GameModules` переводит таблицу в `BattleRules`;
- **сила воинов** — в `GameData.Troops`, рядом с ценой и временем обучения;
- **поменял числа — подними `version`**: итог боя запоминает версию правил (`RulesVersion`).
| Правило | Значение |
| --- | --- |
| Сила стороны | Сумма по типам: воины × сила типа × (100% + контры + осадный бонус) × (100% + бонусы стороны). |
| Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. |
| Осадные | Против стен +50%, в поле −25%. |
| Стены | Защитники за стенами сильнее на 20%. |
| Случайность, ±5% | Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется последней: после контр, стен и бонусов. |
| Голод, −50% | Утверждено 27.10.2026. Сила голодной стороны ×50%. Проверяется на момент боя. Действует и на нападающих, и на защитников. |
| Усталость, −20% | Утверждено 27.10.2026. Сила ×80%. Правило уже в модуле, но **пока никто её не выставляет**: не утверждено, как учитывать смешивание свежих и уставших войск. |
| Голод и усталость вместе | Последовательно: 50% × 80% = **40%** силы (а не 30%). |
| Порядок | Контры и осадные → бонусы стороны → состояния (голод, усталость) → случайность. |
| Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. |
| Потери проигравшего | Половина каждого типа. |
| Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. |
Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами):
| Атакующие | Сила | Исход | Вернутся |
| --- | --- | --- | --- |
| 30 пехоты | 126 против 155 | поражение | 15 |
| 40 пехоты | 168 против 155 | победа | 22 |
| 30 смешанных (12 / 8 / 6 / 4) | 248 против 154 | победа | — |
**Прогноз с учётом случайности** (тот же замок, 10 000 боёв с разными номерами):
| Атакующие | Прогноз | Сила | Потери | Побед на деле |
| --- | --- | --- | --- | --- |
| 30 пехоты | высокий риск поражения | 119–132 против 147–163 | 15 | 0% |
| 36 пехоты | исход неясен | 143–158 против 147–163 | 16–18 | 27% |
| 40 пехоты | исход неясен | 159–176 против 147–163 | 16–20 | 97% |
| 45 пехоты | преимущество | 179–198 против 147–163 | 16–20 | 100% |
| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 235–260 против 146–162 | 7–9 | 100% |
Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся.
Пример: 30 против 20 — победа, погибает 10 атакующих и 10 защитников, домой идут 20.
## События
@@ -93,35 +34,11 @@
## Решения и план
- **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения».
- **Открыто: прогноз.** «Исход неясен» охватывает и 27%, и 97% побед (таблица выше). Показывать ли игроку шанс победы в процентах — решает команда (посчитать его можно точно).
- **Сделано 28.10.2026: голод в бою.** Голод игрока берётся из модуля Населения (`IPopulation.IsStarving`) в момент боя. Пример: 40 пехоты берут замок Змея Горыныча (168 против 155), голодные — нет (84 против 155). Числа — `famineStrengthPercent` и `fatigueStrengthPercent` в `BattleConfig` (версия правил 2).
- **Ограничение голода в офлайне:** если бой прошёл, пока игра была закрыта, берётся голод на момент входа в игру — истории голода у модуля Населения нет. Точно — когда бои будет считать сервер или Население будет хранить, когда начался и кончился голод (договориться с экономикой).
- **Голод гарнизона** пока не нужен: на город игрока никто не нападает. Правило защитника уже есть (`DefenderConditions`).
- **Открыто: порядок бонусов и случайности.** Сейчас случайность применяется последней, после бонусов стороны.
- **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный.
- **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана.
- **Открыто: малые отряды и округление.** Потери округляются вниз по каждому типу, поэтому одиночный проигравший не гибнет никогда. Проверено 28.10.2026:
| Бой | Исход | Потери атакующего | Потери защитника |
| --- | --- | --- | --- |
| 1 против 1 | ничья, защитнику | 0 | 0 |
| 1 против 10 | поражение | 0 | 0 |
| 10 против 1 | победа | 0 | 0 |
Варианты: округлять потери проигравшего вверх; или воспроизводимая случайность для остатка (одиночка гибнет с шансом 50%). Решает команда, тест `Battle_EdgeCases_OutcomeAndArithmetic` правило не фиксирует.
- **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`.
- **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода.
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. Для этого есть `AttackerBonusPercent` / `DefenderBonusPercent` (минус), проценты — из `EconomyConfig`. Голод уже считает модуль Населения (`IsStarving`).
- **Гарнизоны соседей** пока не уменьшаются после боя: соседи — заглушки до сервера.
- **Этап 6:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики.
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 28.10.2026 | Claude | Голод и усталость: `BattleCondition` (флаги), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.…ConditionPercent`; голод −50%, усталость −20%, вместе 40% (последовательно); числа в `BattleConfig` и `BattleRules` (версия 2). Тесты: штрафы, голод переворачивает бой у нападающего и у защитника, прогноз с голодом. |
| 28.10.2026 | Claude | Этап 2 плана: числа боя — в таблицу `BattleConfig` (`Assets/Resources/BattleConfig.asset`) и `BattleRules` с версией; случайность ±5% каждой стороне из `BattleRequest.Seed`; `Forecast` — прогноз риска; `BattleOutcome` симметричный: до боя, выжившие, погибшие, раненые (пока нули) по типам у обеих сторон, номер боя, версия правил, удача сторон. Тесты: случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам, правила из таблицы. |
| 28.10.2026 | Claude | Тест крайних случаев (ревью, п. 2.4): 1×1, 1×10, 10×1, 0×0, пустая сторона, смешанный состав, бонусы −100%, −500%, +100000%, бой числом. Проверяются исход и арифметика (нет отрицательных, выжившие + погибшие = ушедшие). Правило округления потерь — открытый вопрос. |
| 27.10.2026 | Claude | Бой с типами войск: сила по типам из `GameData.Troops`, контры (пехота > кавалерия > лучники > пехота, +25%), стены +20%, осадные +50% против стен и −25% в поле, потери по типам. `BattleRequest` и `BattleOutcome` расширены, старый бой «числом» считается пехотой. Тесты: контры, стены, осадные, потери в пределах состава. |
| 19.10.2026 | Claude | Модуль выделен: `BattleResolver` из `WorldSimulation.cs` стал `BattleSystem` с контрактом `IBattleSystem`; `BattleOutcome` перенесён в ядро. Правила не изменились. |
+4 -20
View File
@@ -15,26 +15,22 @@
| `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`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. |
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. |
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. |
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
| `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. |
| `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). |
@@ -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,17 +53,6 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 31.10.2026 | Claude | `IPopulation.CompleteTraining(type, n)` — передача новобранцев в Армию одним действием. |
| 31.10.2026 | Claude | `IPopulation.Workforce` — все жители (ограничения «40% всех людей» больше нет), `CivilianReserve` — по уровню Ратуши. Население — самостоятельный модуль. |
| 28.10.2026 | Claude | `IPopulation`: `WasStarvingAt` (история голода для боя), найм — `CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`. |
| 28.10.2026 | Claude | Бой: `BattleCondition` (голод, усталость), `BattleRequest.AttackerConditions` / `DefenderConditions`, `BattleOutcome.AttackerConditionPercent` / `DefenderConditionPercent`. |
| 28.10.2026 | Claude | Бой: `BattleRequest.Seed`; `BattleOutcome` — номер боя, версия правил, удача сторон, состав до боя, выжившие, погибшие и раненые по типам у обеих сторон; `IBattleSystem.Forecast`, `BattleForecast`, `BattleChance`. |
| 28.10.2026 | Claude | `IArmy.ReturnFromMarch` и `IArmy.ReturnUnits` возвращают `bool`: зачисление только по известному походу и один раз. `TakeForMarch` отклоняет повторный номер похода. |
| 27.10.2026 | Claude | `IArmy`: гарнизон (`GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`); `TroopsChangedEvent.Garrison`. |
| 27.10.2026 | Claude | `MarchReturnedEvent.TargetEmpty`: войско пришло к уже разбитому лагерю. |
| 27.10.2026 | Claude | `TroopType` (типы войск), `BattleRequest` / `BattleOutcome` с составом по типам, силой сторон и бонусами; `IArmy.GetMarchUnits`, `IArmy.ReturnUnits`. |
| 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 -70
View File
@@ -2,9 +2,7 @@
Ресурсы и производство: сколько у игрока дерева, еды, золота и остального, кто их добывает и тратит.
- **Сборки:**
- `Bogatyri.Economy` — склад ресурсов и здания-рабочие места (Unity);
- `Bogatyri.Economy.Rules` — правила добычи, чистый C# без Unity (папка `Rules`).
- **Сборка:** `Bogatyri.Economy`.
- **Папка:** `Assets/Scripts/Modules/Economy`.
- **Зависит от:** ядра.
@@ -17,43 +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(рабочие, места) × норма × эффективность.
- **Голод** (регламент 31.10, ТЕСТ): пока город голодает (`IPopulation.IsStarving`), добыча всего, кроме еды, ×0.5 (`EconomyConfig`, `famineNonFoodMultiplier`). Еда добывается без штрафа, штраф снимается сразу, как только город снова сыт.
- **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник).
- **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено.
- **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен.
- **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья.
- **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6).
- **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн.
- **Новые места сами не заполняются** (регламент 31.10): построили или улучшили здание — места пустые, пока игрок не поставит рабочих в окне здания. Автоматически — только стартовая расстановка один раз (новая игра или первая загрузка старого сохранения): до 120 рабочих поровну по местам.
- **Голод в офлайне** считается по тем же шагам, что и в игре: Население досчитывает время кусками по 10 минут, и в каждом куске добыча берёт голод на этот момент.
- **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час.
## События
- **Публикует:** `ResourceChangedEvent` — при любом изменении ресурса (тип и новое значение).
@@ -63,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` и т.д.).
@@ -73,45 +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`, не в коде.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 31.10.2026 | Claude | Новые места после стройки и улучшения больше не заполняются сами (`App/CitySync`), только стартовая расстановка один раз. |
| 31.10.2026 | Claude | Голод: добыча всего, кроме еды, ×0.5 (`ProductionRules.FamineNonFoodMultiplier`, ТЕСТ). Тест `Famine_HalvesNonFoodProduction_…`. |
| 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"`. |
-121
View File
@@ -1,121 +0,0 @@
# Население
Люди города: жители, рабочие, прирост, еда, голод, найм в войско и демографические ограничения. Самостоятельный модуль (регламент 28.10 и 31.10.2026). С Поселением (здания, Ратуша) связан только через контракт ядра `ISettlement`. Этап 2 плана разработки.
- **Ответственный:** Валдемар.
- **Сборка:** `Bogatyri.Population` (без Unity).
- **Папка:** `Assets/Scripts/Modules/Population`.
- **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`.
## Как устроено
- **Люди города = жители + новобранцы в Казармах + воины.** Жителей и новобранцев хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
- **Жители:** работать может любой житель — общего ограничения нет (регламент 31.10). Рабочих ограничивают свободные жители и рабочие места в зданиях. Рабочие стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота), остальные свободны. Стартовый город: 120 рабочих из 270 жителей — при первой расстановке (`AutoFillAll`) больше `StartWorkers` (120) не ставится.
- **Найм воинов** (решение команды от 19.10.2026). Воины набираются из свободных жителей (не на работе), гражданский резерв не отдаётся (`Recruitable` = свободные − резерв). Пример команды: 150 свободных, резерв 30 — нанять можно не больше 120. Воин стоит `PeoplePerSoldier` жителей (1). Человек уходит из жителей в начале обучения (`TryRecruit`), учится в Казармах (`InTraining`, ест как воин) и после обучения переходит в Армию (`RecruitsTrained`). Погибшие в бою воины уменьшают население — их больше нет в Армии. Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1–2 — 10, 3–5 — 20, 6–10 — 30 (`EconomyConfig`, `civilianReserveByTownHallLevel`).
- **Группы людей не пересекаются** (регламент 31.10). Каждый человек ровно в одной группе: свободный житель (`Idle`), рабочий (`Workers`), новобранец (`InTraining`), воин (`Soldiers`, хранит Армия). `Total` — их сумма. На работу ставятся только свободные жители, в войско берутся только свободные жители сверх резерва.
- **Рабочих ставит игрок** в окне здания (`SetWorkers`): не больше, чем мест в зданиях (`IProduction.Slots`) и свободных жителей. Автоматически места заполняются только один раз — стартовая расстановка (`AutoFillAll`, до 120 рабочих) в новой игре или при первой загрузке старого сохранения. Новые места после постройки или улучшения остаются пустыми, пока игрок не поставит рабочих. Мест стало меньше — лишних рабочих снимают (`ClampJobs`).
- **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час.
- **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья.
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Добыча всего, кроме еды, ×0.5 (модуль Экономики, регламент 31.10, ТЕСТ), еда добывается без штрафа. Бой ослабляет голодное войско (модуль Боя, −50%). Штраф снимается сразу, как только город снова сыт. Смерти от голода нет.
- **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру.
- **Предел жилья:** `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` | Еды не хватило на последний расчёт. |
| `CivilianReserve` | Гражданский резерв: столько свободных жителей в войско не взять. |
| `Recruitable` | Сколько воинов можно нанять прямо сейчас: (свободные − резерв) / жителей на воина. |
| `InTraining` | Людей, взятых из жителей и ещё обучающихся в Казармах. |
| `TryRecruit(n)`, `RecruitsTrained(n)` | Забрать людей в Казармы в начале обучения; обучение закончено — их считает Армия. |
| `CompleteTraining(type, n)` | Передать n новобранцев в Армию одним действием: люди уходят из Населения, только если Армия их приняла. Иначе ничего не меняется — никто не теряется и не дублируется. Так заканчивают обучение Казармы (`GameProgress`). |
| `WasStarvingAt(unixTime)` | Голодал ли город в этот момент (секунды Unix, время `GameClock`). Позже последнего расчёта -- как сейчас; старше 48 часов -- нет. |
| `Tick(unixNow)` | Досчитать население до момента `unixNow`. |
Получить: `GameServices.Get<IPopulation>()`.
## События
- **Публикует:**
- `PopulationChangedEvent` — изменилось число людей;
- `WorkersChangedEvent` — переставили рабочих;
- `StarvationChangedEvent` — голод начался или закончился.
- **Слушает** (через `App/GameModules`):
- `GameLoadedEvent` — офлайн-прогресс;
- `GameTimeAdvancedEvent` — перемотка в отладочной панели;
- `TroopsChangedEvent`, `TownHallLevelChangedEvent` — пересчёт общего числа и предела.
## Сохранение
Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `training=` (новобранцы в Казармах), `famine=` (история голода: `начало-конец;начало-конец`, конец 0 -- голод идёт), `kit=`.
Сброс (`ResetState`): стартовый город, 270 жителей. Стартовое войско (30 пехоты) и стартовые ресурсы выдаёт `App/GameModules` после сброса и при первой игре (`NewGameStartedEvent`) — один раз (`kit=1`). Старым сохранениям без секции населения стартовый набор не выдаётся.
## Связь с игрой (слой приложения)
| Файл | Что делает |
| --- | --- |
| `App/GameModules.cs` | Создаёт модуль с числами из `EconomyConfig`, подписывает на события, выдаёт стартовый набор, держит ресурс «Люди» на верхней панели равным `Total`. |
| `Meta/Core/GameProgress.cs` | Казармы: перед обучением проверяет `Recruitable`, забирает людей (`TryRecruit`), после обучения — `RecruitsTrained`, потом воины в Армию. |
| `Meta/Panels/TroopsPanel.cs` | Панель «Войска»: «Обучаются в Казармах», «Можно нанять из жителей (резерв N)»; кнопки найма серые, если жителей не хватает. |
| `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 часами;
- частые расчёты дают то же, что один большой;
- часы назад — ничего не ломается;
- перемотка идёт через те же правила;
- сохранение;
- стартовый набор — один раз;
- сброс;
- найм: 150 свободных, резерв 30 (Ратуша 6) → не больше 120; стартовый город — резерв 10; резерв по уровням Ратуши 10/20/30; стартовая расстановка — 120 рабочих; людей столько же, новобранцы не считаются дважды; рабочие остаются на работе; новобранцы едят как воины и сохраняются; старый заказ обучения не уводит в минус;
- история голода: бой офлайн видит голод на свой момент, история сохраняется, сбрасывается и забывается через 48 часов.
## Решения и план
- **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`.
- **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»).
- **Этап 3:** сделано 27.10.2026 — рабочие по профессиям, Дома, добыча в общем расчёте.
- **Вопросы Саши от 28.10.2026 (отчёт по военной части):**
- п. 7, история голода -- сделано (`WasStarvingAt`). Бою нужно перейти с `IsStarving` на `WasStarvingAt(время боя)`. Походы идут по реальному времени, Население -- по `GameClock`: после перемотки в DEV-панели они расходятся.
- п. 8, норма еды на солдата -- сейчас 0.2 еды в час реального времени, игрового календаря нет. Предложение: не вводить; если нужен «игровой месяц» -- 1 месяц = 1 сутки, норма ≈ 5 еды. Решает Дима.
- **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026) — сделано 28.10.2026. Цену воина в ресурсах (`EconomyConfig`, раздел «Солдат»: 15 еды, 5 металла, 5 золота) Казармы пока не используют — у них своя цена в `GameData.Troops`.
- **Схема модулей:** Население — самостоятельный модуль (регламент 28.10 и 31.10.2026). Перенос внутрь Поселения от 28.10 был ошибкой и откатан 31.10.
- **Открыто (регламент 31.10, ждёт решения):** нормы еды для четырёх групп (гражданские и рабочие, новобранцы, солдаты, раненые); переход экономики на игровое время (1 игровой час = 5 реальных минут) и единицы офлайна (48 ч) и истории голода; кто считает предел жилья и рабочие места (по регламенту — Поселение); недоступен ли резерв и для работы; перенос очереди найма из `GameProgress` в Армию (с Сашей).
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. |
| 31.10.2026 | Claude | Автозаполнение новых мест убрано (`AutoFill` удалён, `App/CitySync` больше не ставит рабочих после стройки и улучшения), осталась только стартовая расстановка один раз. При загрузке рабочих не больше, чем жителей. Тесты: группы людей не пересекаются и в сумме дают всех, рабочих не больше свободных, новые места пустые, стартовая расстановка один раз. |
| 31.10.2026 | Claude | По регламенту 31.10: Население снова самостоятельный модуль (`Modules/Population`, сборка `Bogatyri.Population`); убрано ограничение «40% всех людей» — работать может любой житель, первая расстановка — 120 рабочих; резерв по уровню Ратуши 10/20/30 (ТЕСТ, `civilianReserveByTownHallLevel` вместо `civilianReserve`); при голоде добыча кроме еды ×0.5 (ТЕСТ). Тесты. |
| 28.10.2026 | Claude | Население стало частью Поселения: папка `Modules/Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом (`CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`), строка `training=`, `Workforce` — 40% всех людей. Тесты. |
| 28.10.2026 | Claude | История голода: `IPopulation.WasStarvingAt`, строка `famine=` в сохранении, тесты (вопрос Саши п. 7). |
| 27.10.2026 | Claude | Рабочие по профессиям (`Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`, автозаполнение), Дома в пределе жилья, базовый предел 300 (решение 19.10), добыча в общем расчёте. |
| 26.10.2026 | Claude | Создан модуль: жители, еда, прирост, голод, предел жилья, офлайн-прогресс, секция `population`, тесты. |
+16 -74
View File
@@ -1,68 +1,24 @@
# Модульная система «Богатырей»
Игра собрана из десяти модулей вокруг общего **Игрового ядра** (регламент 31.10.2026; модуль Разведки — в ветке Саши `feature/intelligence-m1`, на схеме появится с ней). Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
Игра собрана из восьми модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
```
┌─────────────────────────────────────────┐
│ ОБЩЕЕ ИГРОВОЕ ЯДРО (Core) │
│ контракты · события · реестр · время игры │
└─────────────────────────────────────────┘
все модули видят только ядро, друг друга — нет
┌──────────────────────┐ ┌──────────────────────┐
│ ЭКОНОМИКА │ │ ПОСЕЛЕНИЕ │
│ ресурсы, склады, │ │ здания, Ратуша, │
│ лимиты, добыча │ │ участки, стройка │
│ рабочими │ │ │
└──────────────────────┘ └──────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ НАСЕЛЕНИЕ │ │ АРМИЯ │
│ люди, рабочие, еда, │ │ воины по типам: дома,│
│ голод, прирост, │ │ в гарнизоне, │
│ жильё │ │ в походе │
└──────────────────────┘ └──────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ ГЛОБАЛЬНЫЙ МИР │ │ БОЕВАЯ СИСТЕМА │
│ карта, походы, замок,│ │ сила, контры, стены, │
│ соседи, лагеря │ │ удача ±5%, голод, │
│ разбойников │ │ прогноз │
└──────────────────────┘ └──────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ КРУГ │ │ ЧАТ │
│ объединение игроков, │ │ общий мир и чат │
│ роли │ │ Круга │
└──────────────────────┘ └──────────────────────┘
┌──────────────────────┐
│ СОХРАНЕНИЯ │
│ save.json, у каждого │
│ модуля своя секция │
└──────────────────────┘
App/GameModules — точка сборки: регистрирует модули и соединяет их события
со старыми системами (GameProgress, Казармы, панели UI)
Игровое ядро (Bogatyri.Core)
контракты (IEconomy, IArmy...) · события (EventBus) · реестр (GameServices)
┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┬──────────┬──────────┐
Экономика Поселение Армия Глобальный мир Боевая система Сохранения Чат Круг
ресурсы, здания, войска, карта, урон, потери, игровое мир и объединение
производство развитие характеристики перемещения исход состояние Круг игроков
▲
App/GameModules — точка сборки: регистрирует модули и соединяет их события
со старыми системами (GameProgress, Казармы, панели UI)
```
**Не путать:**
- **Поселение** — это **здания**: что построено и какого уровня. **Население** — это **люди**: сколько их, кто работает, сколько едят. Связь: уровень Ратуши (Поселение) задаёт предел жилья (Население).
- **Армия** хранит, где стоит каждый воин. **Боевая система** только считает бой: кто победил и кто погиб.
- **Глобальный мир** решает, куда и когда идёт войско; бой он просит у Боевой системы.
- **Производство** (`Bogatyri.Economy.Rules`) — не отдельный модуль, а часть Экономики без Unity: добыча через рабочих.
**Кто отвечает** (договорённость команды от 27.10.2026):
| Кто | Модули |
| --- | --- |
| Валдемар (экономика) | Экономика, Население |
| Саша (война и карта) | Армия, Боевая система, Глобальный мир, Круг, Чат |
| Общее | Ядро, Поселение, Сохранения |
| Модуль | Сборка | Папка | Документ |
| --- | --- | --- | --- |
| Игровое ядро | `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.Population` (без Unity) | `Assets/Scripts/Modules/Population` | [Population.md](Population.md) |
| Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.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) |
@@ -90,34 +46,20 @@ App/GameModules — точка сборки: регистрирует модул
EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**.
Уже есть: голод в бою (−50%, с усталостью 40%, у нападающих и защитников, в момент боя), боевой цикл (случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам у обеих сторон, правила из таблицы, тот же поход — тот же бой), безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
Уже есть: Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026):
- [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет);
- [x] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; резерв по уровню Ратуши 10/20/30 (`PopulationTests`);
- [x] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (`ArmyService_Garrison_OneSoldierInOnePlace`);
- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу. Бой уже проверен (`Battle_Famine_HalvesPower_AndStacksWithFatigue`, `World_FamineAtBattleTime_WeakensMarch`: −50% у нападающих и у защитников, вместе с усталостью 40%), история голода на момент боя — `PopulationTests`; добыча кроме еды при голоде ×0.5 и снятие штрафа сразу после еды — `ProductionTests` (31.10);
- [ ] лимит жилья: 300 жителей при лимите 300 — прирост стоит, построен Дом — прирост идёт;
- [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120;
- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть);
- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу;
- [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 31.10.2026 | Claude | Окна зданий: подписи над зданиями убраны, у каждого здания (добывающие, Дом, Склад, Амбар, Ратуша, Кристалл) своё окно по тапу — состояние, добыча и склад, улучшение и его цена. Казармы тоже получили окно и уровни (открывают новые войска вместе с Ратушей; уровни 1/2/3/5 — ТЕСТ). Ратуша улучшается кнопкой в окне, а не тапом. Обновлён Settlement. |
| 31.10.2026 | Claude | По регламенту 31.10: откат — Население снова самостоятельный модуль (`Modules/Population`, `Bogatyri.Population`), схема и «Кто отвечает» как у Саши. Работать может любой житель (без «40%»), резерв по уровню Ратуши 10/20/30, голод режет добычу кроме еды вдвое. Обновлены Population, Settlement, Economy, Core. |
| 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. |
| 28.10.2026 | Claude | Схема модулей обновлена: десять модулей (добавлено Население), «Не путать» (Поселение и Население, Армия и Боевая система), кто за что отвечает. |
| 28.10.2026 | Claude | Голод в бою (−50%) и правило усталости (−20%, вместе 40%). Обновлены Battle, World, Core. |
| 28.10.2026 | Claude | Этап 2 плана, начало: таблица баланса боя `BattleConfig`, случайность ±5%, прогноз риска, полный итог боя для обеих сторон. Обновлены Battle, World, Core. |
| 28.10.2026 | Claude | Исправления по ревью 27.10.2026 (п. 2.1, 2.2, 2.4): воины не появляются из неизвестных и повторных походов, отправка с откатом, тесты крайних случаев боя. Обновлены Army, World, Battle, Core. |
| 27.10.2026 | Claude | Слой приложения: содержимое окон города (`BasePanel`) — в безопасной зоне, левый край больше не уходит под вырез камеры. `SafeArea.fitInsideParent` — для объектов не на весь экран. |
| 27.10.2026 | Claude | Гарнизон города в модуле Армии. Обновлены Army, Core, World. |
| 27.10.2026 | Claude | Лагеря разбойников (PvE) на карте мира. Обновлены World, Battle, Save, Core. |
| 27.10.2026 | Claude | Боевая система с типами войск (контры, стены, осадные, потери по типам), состав гарнизонов на карте, ожидаемый исход в окне атаки. Обновлены Battle, World, Army, Core. |
| 27.10.2026 | Claude | Свободная стройка на сетке: призрак здания, красные занятые клетки, разный размер зданий. Обновлён Settlement. |
| 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. |
+5 -13
View File
@@ -34,11 +34,9 @@ SaveRegistry.Register(myModule); // при запуске модуля
| --- | --- |
| `economy` | Экономика |
| `settlement` | Поселение |
| `world` | Глобальный мир (походы и разбитые лагеря) |
| `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,9 +101,6 @@ SaveRegistry.Register(myModule); // при запуске модуля
| Дата | Кто | Что |
| --- | --- | --- |
| 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. |
| 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`, меню редактора. Кнопка в Настройках теперь сбрасывает и походы, и память Поселения. |
+4 -32
View File
@@ -1,31 +1,11 @@
# Поселение
Здания и развитие города: Ратуша, участки, свободная стройка на сетке, постройка зданий, восстановление города из сохранения. Люди города — отдельный модуль [Население](Population.md), связь только через контракты ядра.
Здания и развитие города: Ратуша, участки под застройку, постройка зданий, восстановление города из сохранения.
- **Ответственность:** совместная.
- **Сборка:** `Bogatyri.Settlement`.
- **Папка:** `Assets/Scripts/Modules/Settlement`.
- **Зависит от:** ядра. Ресурсы на стройку берёт через `IEconomy`.
## Стройка на сетке
Игрок сам выбирает, где строить. Участки (`BuildSlot`) больше не нужны: они и площадки «Застройка» прячутся при запуске, сцену менять не надо.
- **Сетка (`BuildGrid`).** Клетка — 1 единица мира, сетка прямоугольная. Область стройки — прямоугольник вокруг старых участков и Ратуши с запасом `padding` (3 клетки). Можно задать руками: поставить `BuildGrid` на сцену, `autoArea = false`, `areaMin` / `areaMax`. Объект создаётся сам, если его нет.
- **Занятые клетки:** под Ратушей и под каждым построенным зданием. Пересчёт раз в 0.3 с, пока сетка видна, и перед каждой постройкой.
- **Размер здания:** `BuildingData.footprint` (ширина × высота в клетках). `0 × 0` — посчитать по `BoxCollider2D` префаба (иначе по картинке), округлив до клеток. Размер здания задаётся там же, в карточке здания.
- **Призрак (`BuildGhost`):** полупрозрачная копия картинки здания и пятно клеток под ней — зелёное (свободно) или красное (занято / за границей). Только картинка: скриптов и коллайдеров нет, экономика его не видит.
- **Постройка:** `BuildManager.BuildAt(data, cell)` — проверяет клетки и ресурсы, списывает цену, ставит префаб так, чтобы его коллайдер лёг на клетки призрака, публикует `BuildingPlacedEvent`. `CanBuildAt(data, cell)` — та же проверка без постройки.
- **Старые сохранения:** здания встают на свои позиции, занятость считается по тому, какие клетки здание накрывает. Ничего переносить не нужно.
Управление — `Meta/Build/BuildModeController` (слой приложения):
1. «СТРОЙКА» — сетка на земле, подсказка сверху, список зданий (размер в клетках, цена; закрытые по уровню Ратуши — серые).
2. «Выбрать» — призрак встаёт в центр экрана или на ближайшее свободное место.
3. Тащить призрак пальцем (за пятно) или тапнуть по земле — призрак прыгает туда. Камера в это время стоит (`UIBlocker.WorldInputCaptured`).
4. Нижняя панель: «Поставить» (место свободно и хватает ресурсов), «Другое», «Отмена».
5. После постройки режим остаётся; «Здания» сверху — строить дальше, «Выйти» — сетка прячется.
## Контракт `ISettlement`
| Свойство | Что значит |
@@ -59,12 +39,10 @@
| Файл | Что это |
| --- | --- |
| `SettlementModule.cs` | Точка входа: `ISettlement`, сохранение города, восстановление на сцене. |
| `BuildManager.cs` | Список зданий, постройка на сетке (`BuildAt`), поиск данных здания по имени. |
| `BuildGrid.cs` | Сетка стройки: область, занятые клетки, рисунок сетки. Там же `BuildFootprint` — размер здания в клетках. |
| `BuildGhost.cs` | Призрак здания в режиме стройки. |
| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, размер на сетке, цена, нужный уровень Ратуши. |
| `BuildManager.cs` | Режим стройки, список зданий, участки. |
| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, цена, нужный уровень Ратуши. |
| `TownHall.cs` | Ратуша и её улучшение. Раньше файл назывался `NewEmptyCSharpScript.cs`. |
| `BuildSlot.cs` | Участок под здание (старая схема, участки прячутся) и связь здания с участком. |
| `BuildSlot.cs` | Участок под здание и связь здания с участком. |
| `PlacedBuildingId.cs` | Метка построенного здания. Раньше файл назывался `PlaceBuilding.cs`. |
В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`).
@@ -75,17 +53,11 @@
- Лимит населения = база Ратуши + сумма Домов + бонусы, значения из `EconomyConfig`.
- Уровень поселения задаёт доступные уровни и количество жилья, а также минимальный гражданский резерв.
- **Этап 3:** рабочие места в зданиях (`BuildingData`), экран распределения рабочих.
- **Стройка на сетке (27.10.2026, по просьбе Володи):** вернулись к свободной стройке — игрок сам выбирает место, сетка показывает границы и красные занятые клетки, у зданий разный размер.
- **Этап 4 (утверждён без изменений):** доступность зданий зависит от месторождений позиции.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. |
| 31.10.2026 | Claude | Казармы стали уровневым зданием (`ILeveledBuilding`, уровень сохраняется как у остальных). Тап открывает окно Казарм: очередь обучения, какие войска открыты и что нужно для остальных, улучшение, кнопка «Нанять войска». Войско открывается, когда хватает и уровня Ратуши, и уровня Казарм; считаются самые прокачанные Казармы, каждые Казармы по-прежнему дают +1 место в очереди. Уровни Казарм: пехота 1, лучники 2, конница 3, осадные 5 (`EconomyConfig`, `soldier.requiredBarracksLevel`) — **ТЕСТ, согласовать с Димой**; цена улучшения общая, тоже не утверждена. |
| 31.10.2026 | Claude | Откат: Население снова самостоятельный модуль (`Modules/Population`), из Поселения убрано. |
| 28.10.2026 | Claude | Население стало частью Поселения (`Settlement/Population`, сборка `Bogatyri.Settlement.Population`) — как на схеме Димы. Подробности — [Population.md](Population.md). |
| 27.10.2026 | Claude | Свободная стройка на сетке: `BuildGrid`, `BuildGhost`, `BuildingData.footprint`, `BuildManager.BuildAt` / `CanBuildAt` / `FindData`. Участки и площадки «Застройка» прячутся. Новый `BuildModeController` (призрак, «Поставить / Другое / Отмена»), камера не двигается, пока тащат призрак. |
| 19.10.2026 | Claude | `ResetState()` для сброса сохранений. Убранные здания помечаются, чтобы запись в том же кадре их не сохранила. |
| 19.10.2026 | Claude | Модуль выделен. Стройка и Ратуша платят через `IEconomy`. Сохранение города перенесено из `SaveManager` в `SettlementModule` (секция `"settlement"`). Восстановление защищено от двойного запуска и от освобождения занятого участка. Файлы переименованы под имена классов. |
+5 -41
View File
@@ -1,6 +1,6 @@
# Глобальный мир
Карта и перемещения: замок с боссом, 20 соседних городов, 12 лагерей разбойников, походы по реальному времени (идут, даже когда игра закрыта).
Карта и перемещения: замок с боссом, 20 соседних городов, походы по реальному времени (идут, даже когда игра закрыта).
- **Сборки:**
- `Bogatyri.World.Simulation` — карта и походы, чистый C# без Unity (переедет на сервер);
@@ -23,7 +23,7 @@
- `MarchStartedEvent` — войско выступило;
- `MarchBattleResolvedEvent` — бой у цели прошёл;
- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetEmpty` (лагерь уже был разбит, боя не было), `TargetName`, `LootText`.
- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetName`, `LootText`.
**Слушает:** ничего.
@@ -36,9 +36,9 @@
## Сохранение
Секция `"world"`: `{ "marches": [ MarchData ], "camps": [ { "id", "respawnMs" } ] }` — походы и разбитые лагеря с моментом их появления. Старые сохранения без `camps` читаются: все лагеря на месте.
Секция `"world"`: `{ "marches": [ MarchData ] }`.
Сброс (`ResetState`): все походы отменяются, все лагеря на месте. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией.
Сброс (`ResetState`): все походы отменяются. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией.
Раньше походы лежали в `progress.json` (`sashaMarches`). При первом запуске `App/GameModules` переносит их в модуль Мира и удаляет из `progress.json` только после записи `save.json`.
@@ -47,38 +47,9 @@
| Файл | Что это |
| --- | --- |
| `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. |
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей и 12 лагерей из seed. |
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей из seed. |
| `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. |
**Бой с типами войск:**
- у похода есть состав по типам (`MarchData.units`, берётся у Армии при выступлении) и выжившие по типам (`survivorUnits`);
- у цели — состав гарнизона `WorldMapLayout.GarrisonUnits(node)`. Он вычисляется из id цели, а не из генератора карты, поэтому карта осталась прежней. У Змея Горыныча 40% пехоты, 30% лучников, 20% кавалерии, 10% осадных; у соседей пропорции свои у каждого;
- замок и города сражаются за стенами;
- выжившие возвращаются в Армию точно по типам (`IArmy.ReturnUnits`). Старые походы без состава — как раньше, по пропорции;
- окно атаки показывает состав гарнизона и ожидаемый исход: силу сторон, «скорее победа / поражение» и примерные потери. Расчёт тот же, что сделает модуль Боя.
**Лагеря разбойников (PvE):**
- 12 лагерей, по 4 каждого уровня. Ставятся отдельным генератором после городов (`seed ^ CampSeedSalt`), поэтому замок, твой город и соседи остались на прежних местах. Лагерь может стоять ближе к городу (2,6 клетки), чем город к городу (3,6).
| Уровень | Название | Гарнизон | Добыча | Нужно пехоты (примерно) |
| --- | --- | --- | --- | --- |
| 1 | Стан разбойников | 6–9 | 15 еды, 15 дерева | 8–12 |
| 2 | Логово лиходеев | 14–17 | 25 еды, 25 дерева, 8 золота | 18–21 |
| 3 | Становище Соловья-разбойника | 26–29 | 40 еды, 40 дерева, 15 золота, 8 металла | 34–37 |
- бой в поле: стен нет (`WorldMapLayout.IsFortified`), у разбойников нет осадных — их доля уходит пехоте;
- после победы лагерь **разбит** на 30 минут (`CampRules.RespawnMs`, отсчёт от момента боя): на карте серый, с таймером, напасть нельзя;
- если к лагерю шли два войска, бьётся первое пришедшее (бои считаются по времени прихода). Второе находит лагерь пустым: боя нет, домой идут все, добычи нет (`MarchData.targetEmpty`, окно «ЛАГЕРЬ ПУСТ»);
- на карте лагерь — фиолетовый круг, подпись «название, ур. N, гарнизон».
**Отправка целиком или никак** (ревью 27.10.2026, п. 2.2): `MarchManager.TryStartMarch` создаёт поход, затем просит Армию забрать воинов. Если Армия отказала, поход откатывается (`WorldSimulation.CancelMarch`) и игрок видит ошибку. Если при возвращении Армия не знает поход, ни воины, ни добыча не зачисляются (предупреждение в консоли).
**Номер боя:** при выступлении поход получает `battleSeed` (из номера похода, времени выступления и цели) и хранит его в сохранении. Тот же поход всегда даёт тот же бой. После боя в походе запоминаются силы сторон и выпавшая случайность (`attackerPower`, `defenderPower`, `attackerLuck`, `defenderLuck`). Окно итога боя на карте показывает «Сила 356 : 160, удача −3,1% / +3,2%» (твоя / врага); текст окна ужимается, чтобы не уходить под кнопку OK. Окно атаки показывает прогноз риска и диапазоны силы и потерь.
**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Если бой прошёл, пока игра была закрыта, берётся голод на момент входа (истории голода нет). Окно атаки предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Если голод начался или кончился, пока окно атаки открыто, предупреждение и прогноз обновляются сами. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`.
**Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы.
В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`.
**В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену).
@@ -88,18 +59,11 @@
- **Этап 0, решено 19.10.2026:** осталась одна карта мира — эта. Маленькая карта (панель «МИР», `Meta/World`) удалена. Перед удалением проверены ссылки в коде, сценах и префабах. Перенос сохранений: солдаты из её походов (`marches` в `progress.json`) возвращаются в город при загрузке — отправленные, а если отряд уже шёл назад, то выжившие. Добыча таких походов не переносится.
- **Этап 4 (утверждён без изменений):** у позиций на карте — месторождения и их качество.
- **Этап 6:** временные ресурсные точки и кристальные разломы; войска на точке недоступны дома.
- **Лагеря:** числа (гарнизон, добыча, 30 минут до появления) — предложение, требуют балансировки. Позже их можно перенести в `EconomyConfig`. В мультиплеере разбитый лагерь — общий для всех игроков, его состояние будет вести сервер.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. |
| 28.10.2026 | Claude | Номер боя у похода (`battleSeed`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. |
| 28.10.2026 | Claude | По ревью 27.10.2026: отправка с проверкой и откатом (`CancelMarch`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. |
| 27.10.2026 | Claude | В поход уходят только свободные воины: гарнизон остаётся в городе. Верхняя панель карты: «Свободно / Гарнизон / В походе». |
| 27.10.2026 | Claude | Лагеря разбойников (PvE): 12 лагерей трёх уровней, бой в поле, после победы лагерь разбит на 30 минут, второе войско находит его пустым. Секция `world` хранит разбитые лагеря, в `MarchReturnedEvent` добавлен `TargetEmpty`. Тесты: расстановка лагерей, разбит и появляется снова, сохранение. |
| 27.10.2026 | Claude | Бой с типами войск: состав похода и гарнизона, стены у замка и городов, выжившие по типам, ожидаемый исход в окне атаки. Время походов — по `GameClock` (перемотка в DEV проматывает походы). |
| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе. В `MarchReturnedEvent` добавлены `Sent`, `TargetName`, `LootText`. |
| 19.10.2026 | Claude | Маленькая карта мира (`Meta/World`: WorldSystem, WorldMapPanel, WorldData, WorldLine) удалена, окно `Panel_World` убрано из сцены города. Кнопка «МИР» открывает эту карту. Солдаты из походов старой карты возвращаются в город. |
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. |
+1 -1
View File
@@ -264,7 +264,7 @@ PlayerSettings:
templateDefaultScene: Assets/Scenes/SampleScene.unity
useCustomMainManifest: 0
useCustomLauncherManifest: 0
useCustomMainGradleTemplate: 0
useCustomMainGradleTemplate: 1
useCustomLauncherGradleManifest: 0
useCustomBaseGradleTemplate: 0
useCustomGradlePropertiesTemplate: 0