Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a54890a09 | ||
|
|
ed01ba67bd | ||
|
|
bf2b193497 | ||
|
|
10b58fee22 | ||
|
|
6db08851c9 | ||
|
|
5001144147 | ||
|
|
5581b4d2c9 | ||
|
|
2e87b10a58 | ||
|
|
eeb90b8cb6 | ||
|
|
ebb03489d0 | ||
|
|
721881b844 | ||
|
|
99b7d38b6e | ||
|
|
b11b97607f | ||
|
|
4865b14ff2 | ||
|
|
b1a396e9a2 | ||
|
|
fa7ca1a28d | ||
|
|
c7013508a4 | ||
|
|
20b88a6cc2 | ||
|
|
a43d6312f3 | ||
|
|
460d1db2de | ||
|
|
f188c3a8eb |
@@ -0,0 +1,23 @@
|
|||||||
|
%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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b666973e44a2442b82f2782adc99852f
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7264ce138a844adc81fa69b77a27c8a6
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -1,22 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
// Окно здания и рабочих (этап 3). Открывается тапом по зданию или кнопкой "Рабочие" в панели DEV.
|
// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь).
|
||||||
// - сверху: что это за здание, его уровень и кнопка "Улучшить" (больше мест и эффективность / больше лимит / больше жилья);
|
// У каждого здания своё окно:
|
||||||
// - ниже: все профессии с "−" и "+", сколько рабочих стоит, сколько мест и сколько добывается в час;
|
// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+",
|
||||||
// - внизу: "Свободно: N из M".
|
// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена;
|
||||||
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль добычи.
|
// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена;
|
||||||
|
// - Дом: сколько жилья даёт, население / предел, улучшение и цена;
|
||||||
|
// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения;
|
||||||
|
// - Кристалл: где добывается, запас / лимит;
|
||||||
|
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
|
||||||
|
// кнопка "Нанять войска" (панель "Войска").
|
||||||
|
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
|
||||||
|
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
|
||||||
|
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
|
||||||
public static class BuildingWindow
|
public static class BuildingWindow
|
||||||
{
|
{
|
||||||
static GameObject root;
|
static GameObject root;
|
||||||
static GameObject focus; // здание, по которому тапнули (null -- просто список рабочих)
|
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 OnWorkplaceTapped(ResourceCollector building) { Open(building != null ? building.gameObject : null); }
|
||||||
public static void OnStorageTapped(StorageBuilding 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)
|
public static void Open(GameObject building)
|
||||||
{
|
{
|
||||||
// В режиме стройки тап -- это выбор участка, а не здания
|
// В режиме стройки тап -- это выбор места, а не здания
|
||||||
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
||||||
focus = building;
|
focus = building;
|
||||||
Rebuild();
|
Rebuild();
|
||||||
@@ -24,135 +37,440 @@ public static class BuildingWindow
|
|||||||
|
|
||||||
public static void Close()
|
public static void Close()
|
||||||
{
|
{
|
||||||
if (root != null) Object.Destroy(root);
|
if (root != null) UnityEngine.Object.Destroy(root);
|
||||||
root = null;
|
root = null;
|
||||||
focus = null;
|
focus = null;
|
||||||
|
live.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void Rebuild()
|
static void Rebuild()
|
||||||
{
|
{
|
||||||
if (root != null) Object.Destroy(root);
|
if (root != null) UnityEngine.Object.Destroy(root);
|
||||||
|
live.Clear();
|
||||||
var card = UIKit.Modal(TitleFor(focus), out root);
|
var card = UIKit.Modal(TitleFor(focus), out root);
|
||||||
|
root.AddComponent<BuildingWindowTicker>();
|
||||||
|
|
||||||
if (focus != null) BuildingSection(card, focus);
|
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
|
||||||
WorkersSection(card);
|
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);
|
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)
|
static string TitleFor(GameObject building)
|
||||||
{
|
{
|
||||||
|
focusSet = building != null;
|
||||||
if (building == null) return "РАБОЧИЕ";
|
if (building == null) return "РАБОЧИЕ";
|
||||||
|
var townHall = building.GetComponent<TownHall>();
|
||||||
|
if (townHall != null) return $"РАТУША — {townHall.currentLevel} УР.";
|
||||||
var id = building.GetComponent<PlacedBuildingId>();
|
var id = building.GetComponent<PlacedBuildingId>();
|
||||||
var leveled = building.GetComponent<ILeveledBuilding>();
|
var leveled = building.GetComponent<ILeveledBuilding>();
|
||||||
string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name;
|
string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name;
|
||||||
return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper();
|
return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Здание: что даёт и улучшение ----------------
|
// ---------------- Добывающее здание ----------------
|
||||||
|
|
||||||
static void BuildingSection(RectTransform card, GameObject building)
|
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();
|
var config = EconomyConfig.Load();
|
||||||
var rc = building.GetComponent<ResourceCollector>();
|
int level = townHall.currentLevel;
|
||||||
var storage = building.GetComponent<StorageBuilding>();
|
int next = level + 1;
|
||||||
var leveled = building.GetComponent<ILeveledBuilding>();
|
var p = config.population;
|
||||||
string info;
|
|
||||||
|
|
||||||
if (rc != null && WorkJobs.TryFromResource(rc.resourceType, out WorkJob job) && GameModules.Production != null)
|
UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.",
|
||||||
{
|
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||||
var p = GameModules.Production;
|
|
||||||
info = $"Здание даёт {p.BuildingSlots(rc.level)} мест, норма ×{p.BuildingEfficiency(rc.level):0.##}.\n" +
|
|
||||||
$"Всего мест «{WorkJobs.Name(job)}» в городе: {p.Slots(job)}.";
|
|
||||||
}
|
|
||||||
else if (rc != null && rc.IsHouse)
|
|
||||||
{
|
|
||||||
int perLevel = config.population.housingPerHouseLevel;
|
|
||||||
string cap = GameServices.TryGet(out IPopulation pop) ? $"\nНаселение: {pop.Total} / {pop.HousingCap}" : "";
|
|
||||||
info = $"Дом: +{perLevel * rc.level} к пределу жилья (+{perLevel} за уровень).{cap}";
|
|
||||||
}
|
|
||||||
else if (rc != null && rc.resourceType == ResourceType.Gems)
|
|
||||||
{
|
|
||||||
info = "Синий кристалл добывается в кристальных разломах на карте мира.";
|
|
||||||
}
|
|
||||||
else if (storage != null)
|
|
||||||
{
|
|
||||||
var s = config.storage;
|
|
||||||
info = storage.kind == StorageBuilding.Kind.Warehouse
|
|
||||||
? $"Склад: +{s.perWarehouseLevel * storage.level:0} к лимиту дерева, камня, металла и золота."
|
|
||||||
: $"Амбар: +{s.perGranaryLevel * storage.level:0} к лимиту еды.";
|
|
||||||
}
|
|
||||||
else info = "";
|
|
||||||
|
|
||||||
if (info.Length > 0) UIKit.Text(card, info, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
if (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 (leveled == null || (rc != null && rc.resourceType == ResourceType.Gems)) return;
|
if (level >= townHall.maxLevel)
|
||||||
if (leveled.Level >= config.upgrade.maxLevel)
|
|
||||||
{
|
{
|
||||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cost = config.UpgradeCost(leveled.Level);
|
// Что даст следующий уровень
|
||||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
Header(card, $"Уровень {next}");
|
||||||
var b = UIKit.Button(card, $"Улучшить до {leveled.Level + 1} ур. ({CostText(cost)})", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
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 (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||||
leveled.Level = leveled.Level + 1;
|
townHall.Upgrade();
|
||||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||||
UIKit.Toast($"Улучшено до {leveled.Level} уровня");
|
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
|
||||||
Rebuild();
|
Rebuild();
|
||||||
}, -1, 80, 26);
|
}, -1, 80, 26);
|
||||||
b.interactable = canPay;
|
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)
|
static string CostText(ResourceAmount[] cost)
|
||||||
{
|
{
|
||||||
|
GameServices.TryGet(out IEconomy economy);
|
||||||
var parts = new string[cost.Length];
|
var parts = new string[cost.Length];
|
||||||
for (int i = 0; i < cost.Length; i++) parts[i] = $"{cost[i].Amount:0} {GameData.ResName(cost[i].Type).ToLower()}";
|
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);
|
return string.Join(", ", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Рабочие по профессиям ----------------
|
// ---------------- Склад: полоска и время до заполнения ----------------
|
||||||
|
|
||||||
static void WorkersSection(RectTransform card)
|
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))
|
if (!GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod))
|
||||||
{
|
{
|
||||||
UIKit.Text(card, "Модули Населения и добычи не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
|
UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int j = 0; j < WorkJobs.Count; j++)
|
for (int j = 0; j < WorkJobs.Count; j++)
|
||||||
{
|
{
|
||||||
var job = (WorkJob)j;
|
var job = (WorkJob)j;
|
||||||
int slots = prod.Slots(job);
|
|
||||||
int workers = pop.GetWorkers(job);
|
|
||||||
var row = UIKit.Row(card, 72, 8);
|
var row = UIKit.Row(card, 72, 8);
|
||||||
|
var label = Live(row, () =>
|
||||||
string rate = slots > 0 ? $" <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>" : "";
|
{
|
||||||
var label = UIKit.Text(row, slots > 0 ? $"{WorkJobs.Name(job)}: {workers} / {slots}{rate}" : $"{WorkJobs.Name(job)}: нет зданий",
|
int slots = prod.Slots(job);
|
||||||
24, slots > 0 ? UIKit.Theme.Text : UIKit.Theme.TextDim);
|
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.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24;
|
||||||
|
label.alignment = TextAlignmentOptions.Left;
|
||||||
UIKit.Size(label, -1, 72, 1);
|
UIKit.Size(label, -1, 72, 1);
|
||||||
|
|
||||||
StepButton(row, "−5", job, -5, slots > 0 && workers > 0);
|
StepButton(row, "−5", job, -5);
|
||||||
StepButton(row, "−1", job, -1, slots > 0 && workers > 0);
|
StepButton(row, "−1", job, -1);
|
||||||
StepButton(row, "+1", job, +1, slots > 0 && workers < slots && pop.FreeWorkers > 0);
|
StepButton(row, "+1", job, +1);
|
||||||
StepButton(row, "+5", job, +5, slots > 0 && workers < slots && pop.FreeWorkers > 0);
|
StepButton(row, "+5", job, +5);
|
||||||
}
|
}
|
||||||
|
|
||||||
UIKit.Text(card, $"Свободно: <b>{pop.FreeWorkers}</b> из {pop.Workforce} рабочих • остальные жители: {Mathf.Max(0, pop.Civilians - pop.Workforce)}",
|
Live(card, () => $"Свободных жителей: <b>{pop.FreeWorkers}</b> из {pop.Civilians}", 24, UIKit.Theme.Gold);
|
||||||
24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void StepButton(Transform row, string label, WorkJob job, int delta, bool enabled)
|
// Кнопка "−" / "+": активна, только если есть кого снять или куда и кого поставить
|
||||||
|
static void StepButton(Transform row, string label, WorkJob job, int delta)
|
||||||
{
|
{
|
||||||
var b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
|
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);
|
if (GameServices.TryGet(out IPopulation pop)) pop.SetWorkers(job, pop.GetWorkers(job) + delta);
|
||||||
Rebuild();
|
Tick();
|
||||||
}, 86, 64, 24);
|
}, 86, 64, 24);
|
||||||
b.interactable = enabled;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,21 +117,13 @@ public class CitySync : MonoBehaviour
|
|||||||
var population = GameModules.Population;
|
var population = GameModules.Population;
|
||||||
if (production == null || population == null) return;
|
if (production == null || population == null) return;
|
||||||
|
|
||||||
// Места для рабочих: где мест стало больше (построили, улучшили) -- ставим туда свободных
|
// Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам
|
||||||
var before = new int[WorkJobs.Count];
|
// не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем.
|
||||||
for (int j = 0; j < WorkJobs.Count; j++) before[j] = production.Slots((WorkJob)j);
|
|
||||||
production.SetBuildings(city.Workplaces);
|
production.SetBuildings(city.Workplaces);
|
||||||
population.SetHouseLevels(city.HouseLevels);
|
population.SetHouseLevels(city.HouseLevels);
|
||||||
population.ClampJobs();
|
population.ClampJobs();
|
||||||
|
|
||||||
if (!population.JobsInitialized)
|
// Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения
|
||||||
{
|
if (!population.JobsInitialized) population.AutoFillAll();
|
||||||
population.AutoFillAll(); // первый запуск после этапа 3 или новая игра
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (int j = 0; j < WorkJobs.Count; j++)
|
|
||||||
if (production.Slots((WorkJob)j) > before[j]) population.AutoFill((WorkJob)j);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ using UnityEngine.UI;
|
|||||||
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
|
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
|
||||||
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
|
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
|
||||||
//
|
//
|
||||||
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение
|
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение
|
||||||
// войск пока идут по реальным часам.
|
// войск пока идёт по реальным часам.
|
||||||
public class DebugPanel : MonoBehaviour
|
public class DebugPanel : MonoBehaviour
|
||||||
{
|
{
|
||||||
static DebugPanel instance;
|
static DebugPanel instance;
|
||||||
@@ -38,15 +38,23 @@ public class DebugPanel : MonoBehaviour
|
|||||||
|
|
||||||
void 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
|
// Кнопка-переключатель слева посередине: там нет кнопок HUD
|
||||||
var toggle = UIKit.Button(transform, "DEV", new Color(0.55f, 0.15f, 0.55f, 0.85f), Toggle, 110, 64, 26);
|
var toggle = UIKit.Button(transform, "DEV", new Color(0.8f, 0.2f, 0.8f, 1f), Toggle, 110, 64, 28);
|
||||||
var trt = (RectTransform)toggle.transform;
|
var trt = (RectTransform)toggle.transform;
|
||||||
trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f);
|
trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f);
|
||||||
trt.pivot = new Vector2(0, 0.5f);
|
trt.pivot = new Vector2(0, 0.5f);
|
||||||
trt.anchoredPosition = new Vector2(12, 0);
|
trt.anchoredPosition = new Vector2(12, 0);
|
||||||
trt.sizeDelta = new Vector2(110, 64);
|
trt.sizeDelta = new Vector2(110, 64);
|
||||||
|
|
||||||
var card = UIKit.Card(transform, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
|
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.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
|
||||||
card.pivot = new Vector2(0, 0.5f);
|
card.pivot = new Vector2(0, 0.5f);
|
||||||
card.anchoredPosition = new Vector2(135, 0);
|
card.anchoredPosition = new Vector2(135, 0);
|
||||||
@@ -73,6 +81,7 @@ public class DebugPanel : MonoBehaviour
|
|||||||
|
|
||||||
var row4 = UIKit.Row(card, 70, 10);
|
var row4 = UIKit.Row(card, 70, 10);
|
||||||
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
|
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
|
||||||
|
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
|
||||||
|
|
||||||
panel.SetActive(false);
|
panel.SetActive(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class EconomyConfig : ScriptableObject
|
|||||||
|
|
||||||
[Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")]
|
[Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")]
|
||||||
public int maxOfflineHours = 48;
|
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]
|
[Serializable]
|
||||||
@@ -60,6 +63,8 @@ public class EconomyConfig : ScriptableObject
|
|||||||
public int workerSlotsPerLevel = 10;
|
public int workerSlotsPerLevel = 10;
|
||||||
[Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")]
|
[Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")]
|
||||||
public float efficiencyPerLevel = 0.1f;
|
public float efficiencyPerLevel = 0.1f;
|
||||||
|
[Tooltip("Голод: множитель добычи всех ресурсов, кроме еды (0.5 -- вдвое меньше). Еда добывается без штрафа. Регламент 31.10, ТЕСТ")]
|
||||||
|
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
@@ -104,14 +109,18 @@ public class EconomyConfig : ScriptableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
public class SoldierSettings // этап 5: цена солдата (пока не используется)
|
public class SoldierSettings // этап 5: цена солдата
|
||||||
{
|
{
|
||||||
|
[Tooltip("Сколько жителей стоит один воин (берутся из жителей при начале обучения)")]
|
||||||
public int people = 1;
|
public int people = 1;
|
||||||
public float food = 15f;
|
public float food = 15f;
|
||||||
public float iron = 5f;
|
public float iron = 5f;
|
||||||
public float gold = 5f;
|
public float gold = 5f;
|
||||||
[Tooltip("Время подготовки, секунд. Не утверждено")]
|
[Tooltip("Время подготовки, секунд. Не утверждено")]
|
||||||
public int trainSeconds = 60;
|
public int trainSeconds = 60;
|
||||||
|
[Tooltip("Уровень Казарм, с которого открывается тип войск (порядок как в GameData.Troops: пехота, лучники, кавалерия, осадные). " +
|
||||||
|
"Нужен и уровень Ратуши. Считаются самые прокачанные Казармы. ТЕСТ, не утверждено")]
|
||||||
|
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ public static class GameModules
|
|||||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||||||
static void Register()
|
static void Register()
|
||||||
{
|
{
|
||||||
// Боевая система -- чистый C#, сама зарегистрироваться не может
|
// Боевая система -- чистый C#, сама зарегистрироваться не может.
|
||||||
GameServices.Register<IBattleSystem>(new BattleSystem());
|
// Числа боя -- из таблицы 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#, единственное место хранения войск (секция "army")
|
// Армия -- чистый C#, единственное место хранения войск (секция "army")
|
||||||
var army = new ArmyService();
|
var army = new ArmyService();
|
||||||
@@ -47,6 +50,8 @@ public static class GameModules
|
|||||||
ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped;
|
ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped;
|
||||||
StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped;
|
StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped;
|
||||||
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<GameLoadedEvent>(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс
|
||||||
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
|
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
|
||||||
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
|
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
|
||||||
@@ -146,7 +151,11 @@ public static class GameModules
|
|||||||
HousingBase = p.housingBase,
|
HousingBase = p.housingBase,
|
||||||
HousingPerTownHallLevel = p.housingPerTownHallLevel,
|
HousingPerTownHallLevel = p.housingPerTownHallLevel,
|
||||||
HousingPerHouseLevel = p.housingPerHouseLevel,
|
HousingPerHouseLevel = p.housingPerHouseLevel,
|
||||||
MaxOfflineHours = p.maxOfflineHours
|
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +168,8 @@ public static class GameModules
|
|||||||
PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour },
|
PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour },
|
||||||
SlotsPerBuilding = p.workerSlotsT1,
|
SlotsPerBuilding = p.workerSlotsT1,
|
||||||
SlotsPerLevel = p.workerSlotsPerLevel,
|
SlotsPerLevel = p.workerSlotsPerLevel,
|
||||||
EfficiencyPerLevel = p.efficiencyPerLevel
|
EfficiencyPerLevel = p.efficiencyPerLevel,
|
||||||
|
FamineNonFoodMultiplier = p.famineNonFoodMultiplier
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ public class CameraController : MonoBehaviour
|
|||||||
void Update()
|
void Update()
|
||||||
{
|
{
|
||||||
// --- Перемещение по зажатой левой кнопке мыши ---
|
// --- Перемещение по зажатой левой кнопке мыши ---
|
||||||
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI()) // не тащим камеру, когда листаем панель
|
if (UIBlocker.WorldInputCaptured) _isDragging = false; // палец тащит призрак здания -- камера стоит
|
||||||
|
|
||||||
|
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI() && !UIBlocker.WorldInputCaptured) // не тащим камеру, когда листаем панель
|
||||||
{
|
{
|
||||||
_lastMousePosition = Input.mousePosition;
|
_lastMousePosition = Input.mousePosition;
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ using UnityEngine;
|
|||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
|
|
||||||
// Полоска над зданием (этап 3): рабочие этой профессии / места в городе.
|
// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию).
|
||||||
// Раньше показывала накопленный запас кликера -- кликер убран, добычу дают рабочие.
|
// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается.
|
||||||
// У Дома и Кристалла рабочих нет -- там подпись уровня, полоска скрыта.
|
|
||||||
public class BuildingUI : MonoBehaviour
|
public class BuildingUI : MonoBehaviour
|
||||||
{
|
{
|
||||||
[Header("Настройки")]
|
[Header("Настройки")]
|
||||||
@@ -14,8 +13,16 @@ public class BuildingUI : MonoBehaviour
|
|||||||
|
|
||||||
float next;
|
float next;
|
||||||
|
|
||||||
|
public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
{
|
{
|
||||||
|
if (!ShowLabels)
|
||||||
|
{
|
||||||
|
HideLabel();
|
||||||
|
enabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (building == null)
|
if (building == null)
|
||||||
{
|
{
|
||||||
Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!");
|
Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!");
|
||||||
@@ -25,6 +32,18 @@ public class BuildingUI : MonoBehaviour
|
|||||||
UpdateUI();
|
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()
|
void Update()
|
||||||
{
|
{
|
||||||
if (Time.unscaledTime < next) return;
|
if (Time.unscaledTime < next) return;
|
||||||
|
|||||||
@@ -5,13 +5,25 @@ using TMPro;
|
|||||||
// КАЗАРМЫ -- здание для найма войск.
|
// КАЗАРМЫ -- здание для найма войск.
|
||||||
// - Без казарм нанимать войска нельзя.
|
// - Без казарм нанимать войска нельзя.
|
||||||
// - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5).
|
// - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5).
|
||||||
// - Нажатие на казармы открывает панель "Войска" (найм).
|
// - Уровень Казарм открывает новые типы войск (вместе с уровнем Ратуши; числа -- EconomyConfig, раздел "Солдат", ТЕСТ).
|
||||||
// - Над зданием подпись: что сейчас обучается и сколько осталось.
|
// Считаются самые прокачанные Казармы. Уровень сохраняет модуль Поселения (ILeveledBuilding).
|
||||||
|
// - Тап по казармам открывает окно Казарм (App/BuildingWindow): очередь, войска, улучшение, кнопка "Нанять войска".
|
||||||
|
// - Подписи над зданием больше нет -- всё в окне (вернуть: ShowLabel = true).
|
||||||
// Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels).
|
// Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels).
|
||||||
public class Barracks : MonoBehaviour
|
public class Barracks : MonoBehaviour, ILeveledBuilding
|
||||||
{
|
{
|
||||||
public static readonly List<Barracks> All = new List<Barracks>();
|
public static readonly List<Barracks> All = new List<Barracks>();
|
||||||
public static int Count { get { return All.Count; } }
|
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); } }
|
public static int QueueSize { get { return Count == 0 ? 0 : Mathf.Min(5, 2 + Count); } }
|
||||||
@@ -24,6 +36,7 @@ public class Barracks : MonoBehaviour
|
|||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
{
|
{
|
||||||
|
if (!ShowLabel) return;
|
||||||
// подпись над зданием (обычный 3D-текст, не UI)
|
// подпись над зданием (обычный 3D-текст, не UI)
|
||||||
var go = new GameObject("BarracksLabel");
|
var go = new GameObject("BarracksLabel");
|
||||||
go.transform.SetParent(transform, false);
|
go.transform.SetParent(transform, false);
|
||||||
@@ -44,6 +57,7 @@ public class Barracks : MonoBehaviour
|
|||||||
|
|
||||||
void Update()
|
void Update()
|
||||||
{
|
{
|
||||||
|
if (label == null) return;
|
||||||
if (Time.unscaledTime < next) return;
|
if (Time.unscaledTime < next) return;
|
||||||
next = Time.unscaledTime + 0.5f;
|
next = Time.unscaledTime + 0.5f;
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -64,10 +78,18 @@ public class Barracks : MonoBehaviour
|
|||||||
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
|
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Vector3 downPos;
|
||||||
|
|
||||||
void OnMouseDown()
|
void OnMouseDown()
|
||||||
|
{
|
||||||
|
downPos = Input.mousePosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тап (а не перетаскивание камеры) -- окно Казарм
|
||||||
|
void OnMouseUpAsButton()
|
||||||
{
|
{
|
||||||
if (UIBlocker.IsPointerOverUI()) return;
|
if (UIBlocker.IsPointerOverUI()) return;
|
||||||
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
|
BuildingWindow.Open(gameObject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,18 @@ using UnityEngine;
|
|||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
|
|
||||||
// НОВАЯ СХЕМА СТРОЙКИ (как в Rise of Kingdoms):
|
// СВОБОДНАЯ СТРОЙКА НА СЕТКЕ (игрок сам выбирает место):
|
||||||
// 1. Кнопка "СТРОЙКА" включает режим строительства -- появляются свободные участки и подсказка сверху.
|
// 1. Кнопка "СТРОЙКА" включает режим: на земле появляется сетка (красные клетки заняты), сверху подсказка,
|
||||||
// 2. Тап по свободному участку -> окно со зданиями (цена, что доступно по уровню Ратуши).
|
// и сразу открывается список зданий.
|
||||||
// 3. "Построить" -> здание встаёт на участок. Режим остаётся включённым, можно строить дальше.
|
// 2. Выбрали здание -> появляется призрак: полупрозрачное здание и пятно его клеток.
|
||||||
// 4. Кнопка "ВЫЙТИ" (та же кнопка или в подсказке) -- выключает режим, участки снова прячутся.
|
// Зелёное пятно -- можно ставить, красное -- занято или за границей сетки.
|
||||||
// Вне режима пустые участки не видны и не мешают, при запуске игры ничего не открывается.
|
// 3. Призрак двигают пальцем (тащить за само пятно) или тапом по земле -- он прыгает туда.
|
||||||
|
// Камера в это время не ездит. Размер у зданий разный (BuildingData.footprint).
|
||||||
|
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
|
||||||
|
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
|
||||||
//
|
//
|
||||||
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
|
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
|
||||||
|
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
|
||||||
public class BuildModeController : MonoBehaviour
|
public class BuildModeController : MonoBehaviour
|
||||||
{
|
{
|
||||||
public static BuildModeController Instance { get; private set; }
|
public static BuildModeController Instance { get; private set; }
|
||||||
@@ -24,14 +28,27 @@ public class BuildModeController : MonoBehaviour
|
|||||||
|
|
||||||
GameObject banner;
|
GameObject banner;
|
||||||
GameObject chooser;
|
GameObject chooser;
|
||||||
|
GameObject placePanel;
|
||||||
|
TextMeshProUGUI placeInfo;
|
||||||
|
Button placeButton;
|
||||||
|
|
||||||
|
BuildGhost ghost;
|
||||||
|
bool dragging;
|
||||||
|
Vector2 grabOffset;
|
||||||
Vector2 downPos;
|
Vector2 downPos;
|
||||||
bool downOverUI;
|
bool downOverUI;
|
||||||
float nextVisualRefresh;
|
bool downOnGhost;
|
||||||
|
float nextPanelRefresh;
|
||||||
|
|
||||||
static readonly Color SlotColor = new Color(0.3f, 1f, 0.4f, 0.75f);
|
const float TapThreshold = 25f;
|
||||||
|
|
||||||
void Awake() { Instance = this; }
|
void Awake() { Instance = this; }
|
||||||
void OnDestroy() { if (Instance == this) Instance = null; }
|
|
||||||
|
void OnDestroy()
|
||||||
|
{
|
||||||
|
if (Instance == this) Instance = null;
|
||||||
|
UIBlocker.WorldInputCaptured = false;
|
||||||
|
}
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
{
|
{
|
||||||
@@ -45,85 +62,172 @@ public class BuildModeController : MonoBehaviour
|
|||||||
{
|
{
|
||||||
IsActive = active;
|
IsActive = active;
|
||||||
CloseChooser();
|
CloseChooser();
|
||||||
ApplySlotVisuals();
|
CancelGhost();
|
||||||
UpdateButtonLabel();
|
UpdateButtonLabel();
|
||||||
|
BuildGrid.Instance.ShowOverlay(active);
|
||||||
|
|
||||||
if (banner != null) { Destroy(banner); banner = null; }
|
if (banner != null) { Destroy(banner); banner = null; }
|
||||||
if (active)
|
if (active)
|
||||||
{
|
{
|
||||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
||||||
banner = CreateBanner();
|
banner = CreateBanner();
|
||||||
if (FreeSlots().Count == 0) UIKit.Toast("Свободных участков нет. Улучшайте Ратушу и сносите старое.");
|
OpenChooser();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Update()
|
void Update()
|
||||||
{
|
{
|
||||||
if (Time.unscaledTime >= nextVisualRefresh)
|
|
||||||
{
|
|
||||||
nextVisualRefresh = Time.unscaledTime + 0.3f;
|
|
||||||
ApplySlotVisuals(); // участки могли освободиться/заняться (снос, загрузка сохранения)
|
|
||||||
}
|
|
||||||
if (!IsActive) return;
|
if (!IsActive) return;
|
||||||
// открыли любую панель HUD -- выходим из режима стройки
|
// открыли любую панель HUD -- выходим из режима стройки
|
||||||
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; }
|
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; }
|
||||||
if (chooser != null) 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 (Input.GetMouseButtonDown(0))
|
if (Input.GetMouseButtonDown(0))
|
||||||
{
|
{
|
||||||
downPos = Input.mousePosition;
|
downPos = Input.mousePosition;
|
||||||
downOverUI = UIBlocker.IsPointerOverUI();
|
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))
|
if (Input.GetMouseButtonUp(0))
|
||||||
{
|
{
|
||||||
Vector2 up = Input.mousePosition;
|
bool wasDragging = dragging;
|
||||||
if (downOverUI || UIBlocker.IsPointerOverUI() || (up - downPos).magnitude > 25f) return;
|
StopDrag();
|
||||||
HandleTap(up);
|
if (wasDragging || downOverUI || UIBlocker.IsPointerOverUI()) return;
|
||||||
|
if (((Vector2)Input.mousePosition - downPos).magnitude > TapThreshold) return; // тащили камеру
|
||||||
|
ghost.MoveNear(world); // тап по земле -- призрак прыгает сюда
|
||||||
|
RefreshPlacePanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void HandleTap(Vector2 screenPos)
|
void StopDrag()
|
||||||
{
|
{
|
||||||
if (BuildManager.Instance == null) return;
|
dragging = false;
|
||||||
var slot = BuildManager.Instance.SlotAtScreen(screenPos);
|
UIBlocker.WorldInputCaptured = false;
|
||||||
if (slot == null) return;
|
}
|
||||||
if (slot.isOccupied)
|
|
||||||
|
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
|
||||||
{
|
{
|
||||||
var id = slot.occupyingBuilding != null ? slot.occupyingBuilding.GetComponent<PlacedBuildingId>() : null;
|
ghost.MoveNear(center);
|
||||||
UIKit.Toast("Участок занят" + (id != null ? ": " + id.buildingName : ""));
|
UIKit.Toast("Свободного места под это здание нет");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
OpenChooser(slot);
|
CreatePlacePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- участки ----------------
|
void CancelGhost()
|
||||||
|
|
||||||
List<BuildSlot> FreeSlots()
|
|
||||||
{
|
{
|
||||||
var list = new List<BuildSlot>();
|
StopDrag();
|
||||||
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None)) if (!s.isOccupied) list.Add(s);
|
if (ghost != null) Destroy(ghost.gameObject);
|
||||||
return list;
|
ghost = null;
|
||||||
|
if (placePanel != null) Destroy(placePanel);
|
||||||
|
placePanel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ApplySlotVisuals()
|
void Place()
|
||||||
{
|
{
|
||||||
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None))
|
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)
|
||||||
{
|
{
|
||||||
var sr = s.GetComponent<SpriteRenderer>();
|
UIKit.Toast("Построено: " + d.buildingName);
|
||||||
if (sr == null) continue;
|
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
|
||||||
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(BuildSlot slot)
|
void OpenChooser()
|
||||||
{
|
{
|
||||||
CloseChooser();
|
CloseChooser();
|
||||||
|
StopDrag();
|
||||||
var bm = BuildManager.Instance;
|
var bm = BuildManager.Instance;
|
||||||
|
if (bm == null) return;
|
||||||
GameObject root;
|
GameObject root;
|
||||||
var card = UIKit.Modal("Что построить?", out root);
|
var card = UIKit.Modal("Что построить?", out root);
|
||||||
chooser = root;
|
chooser = root;
|
||||||
@@ -135,7 +239,7 @@ public class BuildModeController : MonoBehaviour
|
|||||||
card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero;
|
card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
|
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
|
||||||
UIKit.Text(card, "Участок: " + slot.name + " • Ратуша " + th + " ур.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
UIKit.Text(card, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||||
|
|
||||||
var holder = UIKit.Make("ListHolder", card);
|
var holder = UIKit.Make("ListHolder", card);
|
||||||
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
|
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
|
||||||
@@ -144,7 +248,7 @@ public class BuildModeController : MonoBehaviour
|
|||||||
|
|
||||||
// доступные сверху, закрытые по уровню Ратуши -- ниже
|
// доступные сверху, закрытые по уровню Ратуши -- ниже
|
||||||
var all = new List<BuildingData>(bm.allBuildings);
|
var all = new List<BuildingData>(bm.allBuildings);
|
||||||
all.RemoveAll(b => b == null);
|
all.RemoveAll(b => b == null || b.prefab == null);
|
||||||
all.Sort((a, b) => a.requiredTownHallLevel.CompareTo(b.requiredTownHallLevel));
|
all.Sort((a, b) => a.requiredTownHallLevel.CompareTo(b.requiredTownHallLevel));
|
||||||
|
|
||||||
foreach (var data in all)
|
foreach (var data in all)
|
||||||
@@ -152,6 +256,7 @@ public class BuildModeController : MonoBehaviour
|
|||||||
var d = data;
|
var d = data;
|
||||||
bool locked = d.requiredTownHallLevel > th;
|
bool locked = d.requiredTownHallLevel > th;
|
||||||
bool afford = !locked && d.CanAfford();
|
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 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);
|
var icon = UIKit.Box(row, d.icon != null ? Color.white : UIKit.Theme.Gray);
|
||||||
@@ -160,26 +265,18 @@ public class BuildModeController : MonoBehaviour
|
|||||||
|
|
||||||
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
||||||
UIKit.Size(info, -1, -1, 1);
|
UIKit.Size(info, -1, -1, 1);
|
||||||
UIKit.Text(info, d.buildingName, 30, locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
|
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, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
|
UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
|
||||||
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
|
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
|
||||||
|
|
||||||
string label = locked ? "Закрыто" : (afford ? "Построить" : "Мало ресурсов");
|
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
|
||||||
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
string label = locked ? "Закрыто" : "Выбрать";
|
||||||
{
|
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
|
||||||
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;
|
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()
|
void CloseChooser()
|
||||||
@@ -216,12 +313,13 @@ public class BuildModeController : MonoBehaviour
|
|||||||
// полоска сверху экрана (не модальная: остальной экран остаётся кликабельным)
|
// полоска сверху экрана (не модальная: остальной экран остаётся кликабельным)
|
||||||
var bar = UIKit.Row(UIKit.Overlay, -1, 16, new Color(0.1f, 0.25f, 0.12f, 0.92f), 14);
|
var bar = UIKit.Row(UIKit.Overlay, -1, 16, new Color(0.1f, 0.25f, 0.12f, 0.92f), 14);
|
||||||
bar.name = "BuildModeBanner";
|
bar.name = "BuildModeBanner";
|
||||||
bar.anchorMin = new Vector2(0.15f, 1); bar.anchorMax = new Vector2(0.85f, 1);
|
bar.anchorMin = new Vector2(0.1f, 1); bar.anchorMax = new Vector2(0.9f, 1);
|
||||||
bar.pivot = new Vector2(0.5f, 1);
|
bar.pivot = new Vector2(0.5f, 1);
|
||||||
bar.sizeDelta = new Vector2(0, 110); bar.anchoredPosition = new Vector2(0, -150);
|
bar.sizeDelta = new Vector2(0, 110); bar.anchoredPosition = new Vector2(0, -150);
|
||||||
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: нажмите на зелёный участок", 30, Color.white, TextAlignmentOptions.Left);
|
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: двигайте здание по сетке, красное -- занято", 26, Color.white, TextAlignmentOptions.Left);
|
||||||
UIKit.Size(t, -1, -1, 1);
|
UIKit.Size(t, -1, -1, 1);
|
||||||
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 180, 80, 28);
|
UIKit.Button(bar, "Здания", UIKit.Theme.Green, OpenChooser, 180, 80, 28);
|
||||||
|
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 160, 80, 28);
|
||||||
return bar.gameObject;
|
return bar.gameObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -622,6 +622,12 @@ public class GameProgress : MonoBehaviour
|
|||||||
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
|
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
|
||||||
static IArmy Army => GameServices.Get<IArmy>();
|
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 TroopCount(int type) { return Army.GetTroops(type); }
|
||||||
|
|
||||||
public int TotalTroops
|
public int TotalTroops
|
||||||
@@ -629,20 +635,51 @@ public class GameProgress : MonoBehaviour
|
|||||||
get { return Army.SoldiersAtHome; }
|
get { return Army.SoldiersAtHome; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; }
|
// Тип войск открыт, если выполнены оба условия: уровень Ратуши (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 void Train(int index, int count)
|
public void Train(int index, int count)
|
||||||
{
|
{
|
||||||
var def = GameData.Troops[index];
|
var def = GameData.Troops[index];
|
||||||
if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; }
|
|
||||||
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); 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; }
|
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;
|
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;
|
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
|
||||||
if (start < Now) start = Now;
|
if (start < Now) start = Now;
|
||||||
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
|
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
|
||||||
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end });
|
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited });
|
||||||
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
|
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
|
||||||
MarkChanged();
|
MarkChanged();
|
||||||
}
|
}
|
||||||
@@ -681,7 +718,16 @@ public class GameProgress : MonoBehaviour
|
|||||||
{
|
{
|
||||||
var o = Data.training[0];
|
var o = Data.training[0];
|
||||||
Data.training.RemoveAt(0);
|
Data.training.RemoveAt(0);
|
||||||
Army.AddTroops(o.troopIndex, o.count);
|
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); // старый заказ: людей под него не брали
|
||||||
Data.troopsTrained += o.count;
|
Data.troopsTrained += o.count;
|
||||||
Data.dailyTroops += o.count;
|
Data.dailyTroops += o.count;
|
||||||
Data.eventTroopRemainder += o.count;
|
Data.eventTroopRemainder += o.count;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class TrainingOrder
|
|||||||
public int count;
|
public int count;
|
||||||
public long startTicks;
|
public long startTicks;
|
||||||
public long endTicks;
|
public long endTicks;
|
||||||
|
public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
|
|||||||
@@ -24,10 +24,32 @@ public class TroopsPanel : BasePanel
|
|||||||
ProfilePanel.Header(army, "Армия");
|
ProfilePanel.Header(army, "Армия");
|
||||||
ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0"));
|
ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0"));
|
||||||
ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get<IArmy>().TroopsInMarches.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.TroopsPower.ToString("N0"));
|
||||||
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
|
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
|
||||||
UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26);
|
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)
|
if (Barracks.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -82,7 +104,7 @@ public class TroopsPanel : BasePanel
|
|||||||
|
|
||||||
if (!unlocked)
|
if (!unlocked)
|
||||||
{
|
{
|
||||||
UIKit.Text(card, "Откроется на Ратуше " + def.requiredTownHall + " уровня", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +112,22 @@ public class TroopsPanel : BasePanel
|
|||||||
foreach (int amount in Amounts)
|
foreach (int amount in Amounts)
|
||||||
{
|
{
|
||||||
int a = amount;
|
int a = amount;
|
||||||
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize;
|
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize && GP.Recruitable >= a;
|
||||||
var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 80, 30);
|
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);
|
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()
|
protected override void Tick()
|
||||||
{
|
{
|
||||||
var d = GP.Data;
|
var d = GP.Data;
|
||||||
|
|||||||
@@ -64,11 +64,23 @@ public abstract class BasePanel : MonoBehaviour
|
|||||||
t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24);
|
t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24);
|
||||||
}
|
}
|
||||||
content = (RectTransform)t;
|
content = (RectTransform)t;
|
||||||
|
WrapInSafeArea(content);
|
||||||
}
|
}
|
||||||
return 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()
|
void Rebuild()
|
||||||
{
|
{
|
||||||
float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f;
|
float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using System.Text;
|
|||||||
//
|
//
|
||||||
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
|
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
|
||||||
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
|
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
|
||||||
|
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
|
||||||
public sealed class ArmyService : IArmy, ISaveParticipant
|
public sealed class ArmyService : IArmy, ISaveParticipant
|
||||||
{
|
{
|
||||||
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
|
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
|
||||||
@@ -19,6 +20,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly int[] troops = new int[Types];
|
private readonly int[] troops = new int[Types];
|
||||||
|
private readonly int[] garrison = new int[Types];
|
||||||
private readonly List<MarchUnits> marches = new List<MarchUnits>();
|
private readonly List<MarchUnits> marches = new List<MarchUnits>();
|
||||||
|
|
||||||
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
|
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
|
||||||
@@ -48,6 +50,28 @@ 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)
|
public void AddTroops(int type, int count)
|
||||||
{
|
{
|
||||||
if (type < 0 || type >= Types || count <= 0) return;
|
if (type < 0 || type >= Types || count <= 0) return;
|
||||||
@@ -67,41 +91,73 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
|||||||
troops[i] -= l;
|
troops[i] -= l;
|
||||||
lost += l;
|
lost += l;
|
||||||
}
|
}
|
||||||
|
ClampGarrison();
|
||||||
if (lost > 0) Changed();
|
if (lost > 0) Changed();
|
||||||
return lost;
|
return lost;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool TakeForMarch(int marchId, int count)
|
public bool TakeForMarch(int marchId, int count)
|
||||||
{
|
{
|
||||||
int[] taken = ArmyMath.TakeProportional(troops, 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);
|
||||||
if (taken == null) return false;
|
if (taken == null) return false;
|
||||||
|
|
||||||
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
|
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 });
|
marches.Add(new MarchUnits { marchId = marchId, units = taken });
|
||||||
Changed();
|
Changed();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ReturnFromMarch(int marchId, int survivors)
|
// Воины возвращаются только из известного похода, и только один раз: запись похода удаляется.
|
||||||
|
// Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты
|
||||||
|
// появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy.
|
||||||
|
public bool ReturnFromMarch(int marchId, int survivors)
|
||||||
{
|
{
|
||||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||||
if (entry == null)
|
if (entry == null) return false;
|
||||||
{
|
|
||||||
troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой
|
int sent = 0;
|
||||||
}
|
foreach (var u in entry.units) sent += u;
|
||||||
else
|
int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся
|
||||||
{
|
for (int i = 0; i < Types; i++) troops[i] += back[i];
|
||||||
int[] back = ArmyMath.SplitSurvivors(entry.units, survivors);
|
marches.Remove(entry);
|
||||||
for (int i = 0; i < Types; i++) troops[i] += back[i];
|
|
||||||
marches.Remove(entry);
|
|
||||||
}
|
|
||||||
Changed();
|
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()
|
void Changed()
|
||||||
{
|
{
|
||||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
|
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Перенос из старого progress.json ----------------
|
// ---------------- Перенос из старого progress.json ----------------
|
||||||
@@ -112,12 +168,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
|||||||
{
|
{
|
||||||
if (legacyTroops != null)
|
if (legacyTroops != null)
|
||||||
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
|
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
|
||||||
|
Array.Clear(garrison, 0, Types);
|
||||||
|
|
||||||
marches.Clear();
|
marches.Clear();
|
||||||
if (legacyMarches != null)
|
if (legacyMarches != null)
|
||||||
{
|
{
|
||||||
foreach (var m in legacyMarches)
|
foreach (var m in legacyMarches)
|
||||||
{
|
{
|
||||||
|
if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись
|
||||||
var units = new int[Types];
|
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]);
|
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 });
|
marches.Add(new MarchUnits { marchId = m.Key, units = units });
|
||||||
@@ -129,6 +187,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
|||||||
// ---------------- Сохранение (секция "army") ----------------
|
// ---------------- Сохранение (секция "army") ----------------
|
||||||
// Модуль без Unity, поэтому формат -- простые строки:
|
// Модуль без Unity, поэтому формат -- простые строки:
|
||||||
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||||
|
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
|
||||||
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||||
|
|
||||||
public string SaveKey => "army";
|
public string SaveKey => "army";
|
||||||
@@ -137,6 +196,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
|||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.Append("troops=").Append(Join(troops)).Append('\n');
|
sb.Append("troops=").Append(Join(troops)).Append('\n');
|
||||||
|
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
|
||||||
foreach (var m in marches)
|
foreach (var m in marches)
|
||||||
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
|
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -145,6 +205,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
|||||||
public void Restore(string data)
|
public void Restore(string data)
|
||||||
{
|
{
|
||||||
Array.Clear(troops, 0, Types);
|
Array.Clear(troops, 0, Types);
|
||||||
|
Array.Clear(garrison, 0, Types);
|
||||||
marches.Clear();
|
marches.Clear();
|
||||||
RestoredFromSave = true;
|
RestoredFromSave = true;
|
||||||
|
|
||||||
@@ -158,21 +219,29 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
|||||||
int[] t = Parse(line.Substring("troops=".Length));
|
int[] t = Parse(line.Substring("troops=".Length));
|
||||||
Array.Copy(t, troops, Types);
|
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="))
|
else if (line.StartsWith("march="))
|
||||||
{
|
{
|
||||||
string[] parts = line.Substring("march=".Length).Split(':');
|
string[] parts = line.Substring("march=".Length).Split(':');
|
||||||
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id))
|
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)
|
||||||
|
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
|
||||||
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
|
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
|
||||||
Changed();
|
Changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Новая игра: войск нет, походов нет
|
// Новая игра: войск нет, гарнизона и походов нет
|
||||||
public void ResetState()
|
public void ResetState()
|
||||||
{
|
{
|
||||||
Array.Clear(troops, 0, Types);
|
Array.Clear(troops, 0, Types);
|
||||||
|
Array.Clear(garrison, 0, Types);
|
||||||
marches.Clear();
|
marches.Clear();
|
||||||
Changed();
|
Changed();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// ПРАВИЛА БОЯ: все числа боевой системы в одном месте. Документ: 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6a836fd9caa34954b185ca38b4d89afb
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -1,26 +1,216 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
|
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
|
||||||
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
||||||
//
|
//
|
||||||
// Правила пока самые простые: побеждает тот, у кого больше солдат.
|
// Правила (стартовые числа -- в BattleRules, утверждает команда):
|
||||||
// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся.
|
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
|
||||||
|
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
|
||||||
|
// бонус типа против врага взвешивается по составу врага;
|
||||||
|
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
|
||||||
|
// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог;
|
||||||
|
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
|
||||||
|
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
|
||||||
|
// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие.
|
||||||
public sealed class BattleSystem : IBattleSystem
|
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)
|
public BattleOutcome Resolve(BattleRequest request)
|
||||||
{
|
{
|
||||||
int attackers = request.Attackers;
|
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2));
|
||||||
int defenders = request.Defenders;
|
}
|
||||||
bool victory = attackers > defenders; // ничья -- в пользу защитника
|
|
||||||
|
|
||||||
// Проигравший теряет половину своего войска, победитель -- столько же
|
// Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается.
|
||||||
int loserArmy = victory ? defenders : attackers;
|
public BattleForecast Forecast(BattleRequest request)
|
||||||
int losses = (loserArmy + 1) / 2;
|
{
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
return new BattleOutcome
|
return new BattleOutcome
|
||||||
{
|
{
|
||||||
Victory = victory,
|
Victory = victory,
|
||||||
AttackerLosses = losses,
|
BattleId = request.Seed,
|
||||||
DefenderLosses = losses,
|
RulesVersion = rules.Version,
|
||||||
Survivors = attackers - losses
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ using UnityEngine.EventSystems;
|
|||||||
// не собирал ресурсы со здания под панелью, не улучшал Ратушу, не двигал камеру.
|
// не собирал ресурсы со здания под панелью, не улучшал Ратушу, не двигал камеру.
|
||||||
public static class UIBlocker
|
public static class UIBlocker
|
||||||
{
|
{
|
||||||
|
// Палец сейчас занят миром (например, игрок тащит призрак здания в режиме стройки) --
|
||||||
|
// камера в это время не двигается. Ставит и снимает тот, кто захватил ввод.
|
||||||
|
public static bool WorldInputCaptured;
|
||||||
|
|
||||||
public static bool IsPointerOverUI()
|
public static bool IsPointerOverUI()
|
||||||
{
|
{
|
||||||
if (EventSystem.current == null) return false;
|
if (EventSystem.current == null) return false;
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ public interface ILeveledBuilding
|
|||||||
int Level { get; set; }
|
int Level { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Население: жители, еда, прирост ----------------
|
// ---------------- Население: жители, рабочие, еда, голод, прирост, найм ----------------
|
||||||
public interface IPopulation
|
public interface IPopulation
|
||||||
{
|
{
|
||||||
int Total { get; } // все люди города: жители + воины (в городе и в походах)
|
int Total { get; } // все люди города: жители + обучаются в Казармах + воины (в городе и в походах)
|
||||||
int Civilians { get; } // жители (не воины)
|
int Civilians { get; } // жители (не воины)
|
||||||
int Workers { get; } // жители на работе (все профессии вместе)
|
int Workers { get; } // жители на работе (все профессии вместе)
|
||||||
int Idle { get; } // остальные жители: Civilians - Workers
|
int Idle { get; } // остальные жители: Civilians - Workers
|
||||||
int Workforce { get; } // сколько жителей вообще могут работать (взрослые; стартовый город -- 120)
|
int Workforce { get; } // сколько жителей могут работать: все жители (общего ограничения нет, регламент 31.10)
|
||||||
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
|
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
|
||||||
int Soldiers { get; } // воины -- их хранит модуль Армии
|
int Soldiers { get; } // воины -- их хранит модуль Армии
|
||||||
int HousingCap { get; } // предел населения: выше него прирост не идёт
|
int HousingCap { get; } // предел населения: выше него прирост не идёт
|
||||||
@@ -61,6 +61,20 @@ public interface IPopulation
|
|||||||
float FoodPerHour { get; } // сколько город съедает в час
|
float FoodPerHour { get; } // сколько город съедает в час
|
||||||
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
|
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
|
||||||
bool IsStarving { get; } // еды не хватило на последний расчёт
|
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 (вызывается раз в несколько секунд и при перемотке)
|
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
|
||||||
void Tick(long unixNow);
|
void Tick(long unixNow);
|
||||||
@@ -76,16 +90,29 @@ public interface IArmy
|
|||||||
{
|
{
|
||||||
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
|
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
|
||||||
int GetTroops(int type); // воинов этого типа в городе
|
int GetTroops(int type); // воинов этого типа в городе
|
||||||
int SoldiersAtHome { get; } // всего воинов в городе
|
int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном)
|
||||||
int TroopsInMarches { get; } // воинов в походах
|
int TroopsInMarches { get; } // воинов в походах
|
||||||
|
|
||||||
|
// Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется.
|
||||||
|
int GarrisonTotal { get; }
|
||||||
|
int GetGarrison(int type);
|
||||||
|
int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон
|
||||||
|
int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено
|
||||||
|
|
||||||
void AddTroops(int type, int count); // обучены / вернулись
|
void AddTroops(int type, int count); // обучены / вернулись
|
||||||
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
|
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
|
||||||
|
|
||||||
// Забрать воинов из города в поход (запоминает состав похода)
|
// Забрать свободных воинов в поход (запоминает состав похода).
|
||||||
|
// false -- ничего не взято: не хватает воинов или поход с таким номером уже есть.
|
||||||
bool TakeForMarch(int marchId, int count);
|
bool TakeForMarch(int marchId, int count);
|
||||||
// Вернуть выживших из похода по тем же типам войск
|
// Вернуть выживших из похода по тем же типам войск.
|
||||||
void ReturnFromMarch(int marchId, int survivors);
|
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
|
||||||
|
bool ReturnFromMarch(int marchId, int survivors);
|
||||||
|
|
||||||
|
// Состав похода по типам (копия); null -- такого похода нет
|
||||||
|
int[] GetMarchUnits(int marchId);
|
||||||
|
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
|
||||||
|
bool ReturnUnits(int marchId, int[] survivorUnits);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Глобальный мир: карта и перемещения ----------------
|
// ---------------- Глобальный мир: карта и перемещения ----------------
|
||||||
@@ -96,23 +123,94 @@ public interface IWorld
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Боевая система: урон, потери, исход ----------------
|
// ---------------- Боевая система: урон, потери, исход ----------------
|
||||||
public struct BattleRequest
|
// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии
|
||||||
|
public static class TroopType
|
||||||
{
|
{
|
||||||
public int Attackers;
|
public const int Infantry = 0; // пехота
|
||||||
public int Defenders;
|
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 struct BattleOutcome
|
||||||
{
|
{
|
||||||
public bool Victory; // победил атакующий
|
public bool Victory; // победил атакующий
|
||||||
public int AttackerLosses;
|
public long BattleId; // = Seed запроса
|
||||||
|
public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог
|
||||||
|
|
||||||
|
public int AttackerLosses; // выбыли из строя: погибшие + раненые
|
||||||
public int DefenderLosses;
|
public int DefenderLosses;
|
||||||
public int Survivors; // сколько атакующих осталось
|
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 interface IBattleSystem
|
public interface IBattleSystem
|
||||||
{
|
{
|
||||||
BattleOutcome Resolve(BattleRequest request);
|
BattleOutcome Resolve(BattleRequest request);
|
||||||
|
BattleForecast Forecast(BattleRequest request); // Seed не нужен: перебираются крайние значения случайности
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Чат: общий мир и Круг ----------------
|
// ---------------- Чат: общий мир и Круг ----------------
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public struct TroopsChangedEvent
|
|||||||
{
|
{
|
||||||
public int AtHome;
|
public int AtHome;
|
||||||
public int InMarches;
|
public int InMarches;
|
||||||
|
public int Garrison; // из AtHome -- в гарнизоне
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Глобальный мир ----------------
|
// ---------------- Глобальный мир ----------------
|
||||||
@@ -86,6 +87,7 @@ public struct MarchReturnedEvent
|
|||||||
public bool Victory;
|
public bool Victory;
|
||||||
public int Survivors;
|
public int Survivors;
|
||||||
public int Sent; // сколько воинов уходило в поход
|
public int Sent; // сколько воинов уходило в поход
|
||||||
|
public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все
|
||||||
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
|
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
|
||||||
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
|
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,18 @@ public sealed class ProductionRules
|
|||||||
public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30)
|
public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30)
|
||||||
public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено
|
public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено
|
||||||
public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено
|
public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено
|
||||||
|
|
||||||
|
// Голод (регламент 31.10, ТЕСТ): добыча всех ресурсов, кроме еды, ×0.5. Еда -- без штрафа.
|
||||||
|
// Штраф снимается сразу, как только город снова сыт (модуль Населения сообщает IsStarving).
|
||||||
|
public double FamineNonFoodMultiplier = 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md
|
// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md
|
||||||
// Без Unity: правила те же, что будут на сервере.
|
// Без Unity: правила те же, что будут на сервере.
|
||||||
//
|
//
|
||||||
// Ресурс производят люди, здание даёт только места и эффективность:
|
// Ресурс производят люди, здание даёт только места и эффективность:
|
||||||
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий.
|
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий,
|
||||||
|
// а при голоде всё, кроме еды, -- ещё × FamineNonFoodMultiplier (голод считает модуль Населения, IPopulation.IsStarving).
|
||||||
// Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет.
|
// Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет.
|
||||||
// Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт.
|
// Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт.
|
||||||
//
|
//
|
||||||
@@ -80,7 +85,10 @@ public sealed class ProductionService : IProduction, ISaveParticipant
|
|||||||
int j = (int)job;
|
int j = (int)job;
|
||||||
if (!GameServices.TryGet(out IPopulation population)) return 0;
|
if (!GameServices.TryGet(out IPopulation population)) return 0;
|
||||||
int working = Math.Min(population.GetWorkers(job), slots[j]);
|
int working = Math.Min(population.GetWorkers(job), slots[j]);
|
||||||
return working * rules.PerWorkerPerHour[j] * efficiency[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)
|
public void Produce(double hours)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// МОДУЛЬ НАСЕЛЕНИЯ. Документ: Docs/Modules/Population.md
|
||||||
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
|
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
|
||||||
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
|
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
|
||||||
public sealed class PopulationRules
|
public sealed class PopulationRules
|
||||||
@@ -21,6 +22,12 @@ public sealed class PopulationRules
|
|||||||
public int HousingPerTownHallLevel = 50;
|
public int HousingPerTownHallLevel = 50;
|
||||||
public int HousingPerHouseLevel = 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 MaxOfflineHours = 48;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -7,7 +8,17 @@ using System.Text;
|
|||||||
//
|
//
|
||||||
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
|
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
|
||||||
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
|
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
|
||||||
// Жители: часть может работать (Workforce, стартовый город -- 120 из 270), из них рабочие стоят на профессиях.
|
// Жители: работать может любой житель (общего ограничения нет, регламент 31.10) -- рабочих ограничивают
|
||||||
|
// свободные жители и рабочие места в зданиях. Стартовый город: 120 рабочих из 270 жителей.
|
||||||
|
//
|
||||||
|
// Каждый человек ровно в одной группе (регламент 31.10):
|
||||||
|
// свободный житель = Civilians - Workers (Idle)
|
||||||
|
// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных)
|
||||||
|
// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает)
|
||||||
|
// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию)
|
||||||
|
// Total = Civilians + InTraining + Soldiers -- сумма всех групп.
|
||||||
|
// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок.
|
||||||
|
// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять.
|
||||||
//
|
//
|
||||||
// Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре,
|
// Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре,
|
||||||
// при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же.
|
// при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же.
|
||||||
@@ -28,6 +39,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды)
|
double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды)
|
||||||
long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу
|
long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу
|
||||||
bool starving;
|
bool starving;
|
||||||
|
int inTraining; // люди, взятые из жителей в Казармы и ещё не обученные
|
||||||
|
// История голода: отрезки [начало, конец) в Unix-секундах, конец 0 -- голод идёт сейчас.
|
||||||
|
// Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод считается на момент боя.
|
||||||
|
readonly List<long[]> famine = new List<long[]>();
|
||||||
bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение)
|
bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение)
|
||||||
|
|
||||||
public PopulationService(PopulationRules rules)
|
public PopulationService(PopulationRules rules)
|
||||||
@@ -53,16 +68,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
|
|
||||||
public int Idle => Math.Max(0, civilians - Workers);
|
public int Idle => Math.Max(0, civilians - Workers);
|
||||||
|
|
||||||
// Доля жителей, способных работать: 120 из 270 в стартовом городе
|
// Работать может любой житель: общего ограничения нет (регламент 31.10).
|
||||||
public int Workforce
|
// Рабочих ограничивают свободные жители и рабочие места (IProduction.Slots).
|
||||||
{
|
public int Workforce => civilians;
|
||||||
get
|
|
||||||
{
|
|
||||||
int startCiv = Math.Max(1, rules.StartCivilians);
|
|
||||||
double share = Math.Min(1.0, rules.StartWorkers / (double)startCiv);
|
|
||||||
return (int)Math.Floor(civilians * share + Epsilon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public int FreeWorkers => Math.Max(0, Workforce - Workers);
|
public int FreeWorkers => Math.Max(0, Workforce - Workers);
|
||||||
|
|
||||||
@@ -75,7 +83,65 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Total => civilians + Soldiers;
|
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
|
public int HousingCap
|
||||||
{
|
{
|
||||||
@@ -90,9 +156,33 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour;
|
public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour;
|
||||||
public bool IsStarving => starving;
|
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)
|
double FoodPerHourExact(int soldiers)
|
||||||
{
|
{
|
||||||
return civilians * rules.FoodPerCivilianPerHour + soldiers * rules.FoodPerSoldierPerHour;
|
// Обучающиеся в Казармах едят как воины
|
||||||
|
return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetWorkers(WorkJob job)
|
public int GetWorkers(WorkJob job)
|
||||||
@@ -106,7 +196,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
int j = (int)job;
|
int j = (int)job;
|
||||||
if (j < 0 || j >= WorkJobs.Count) return 0;
|
if (j < 0 || j >= WorkJobs.Count) return 0;
|
||||||
|
|
||||||
int limit = jobs[j] + FreeWorkers; // больше, чем есть свободных, не поставить
|
int limit = jobs[j] + FreeWorkers; // только свободные жители: не воины, не новобранцы, не уже работающие
|
||||||
int slots = SlotsFor(job);
|
int slots = SlotsFor(job);
|
||||||
if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях
|
if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях
|
||||||
int value = Math.Max(0, Math.Min(count, limit));
|
int value = Math.Max(0, Math.Min(count, limit));
|
||||||
@@ -146,13 +236,14 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
|
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
|
||||||
if (elapsed > max) elapsed = max;
|
if (elapsed > max) elapsed = max;
|
||||||
|
|
||||||
Simulate(elapsed);
|
Simulate(unixNow - elapsed, elapsed);
|
||||||
lastTickUnix = unixNow;
|
lastTickUnix = unixNow;
|
||||||
|
PruneFamine(unixNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Расчёт ----------------
|
// ---------------- Расчёт ----------------
|
||||||
|
|
||||||
void Simulate(long seconds)
|
void Simulate(long startUnix, long seconds)
|
||||||
{
|
{
|
||||||
if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего
|
if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего
|
||||||
GameServices.TryGet(out IProduction production);
|
GameServices.TryGet(out IProduction production);
|
||||||
@@ -162,6 +253,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
int soldiers = Soldiers;
|
int soldiers = Soldiers;
|
||||||
|
|
||||||
long remaining = seconds;
|
long remaining = seconds;
|
||||||
|
long stepStart = startUnix;
|
||||||
while (remaining > 0)
|
while (remaining > 0)
|
||||||
{
|
{
|
||||||
long step = Math.Min(remaining, StepSeconds);
|
long step = Math.Min(remaining, StepSeconds);
|
||||||
@@ -195,11 +287,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
fed = false;
|
fed = false;
|
||||||
}
|
}
|
||||||
starving = !fed;
|
starving = !fed;
|
||||||
|
RecordFamine(starving, stepStart); // голод начался (кончился) в этом куске -- запоминаем его начало
|
||||||
|
stepStart += step;
|
||||||
|
|
||||||
// 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда.
|
// 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда.
|
||||||
if (fed)
|
if (fed)
|
||||||
{
|
{
|
||||||
int room = HousingCap - (civilians + soldiers);
|
int room = HousingCap - (civilians + inTraining + soldiers);
|
||||||
if (room > 0)
|
if (room > 0)
|
||||||
{
|
{
|
||||||
growthProgress += rules.GrowthPerHour * hours;
|
growthProgress += rules.GrowthPerHour * hours;
|
||||||
@@ -223,25 +317,19 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
|
|
||||||
// ---------------- Рабочие: расстановка ----------------
|
// ---------------- Рабочие: расстановка ----------------
|
||||||
|
|
||||||
// Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено.
|
// Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10).
|
||||||
public int AutoFill(WorkJob job)
|
// Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false).
|
||||||
{
|
// Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки
|
||||||
int slots = SlotsFor(job);
|
// и улучшения остаются пустыми, пока игрок сам не поставит рабочих.
|
||||||
int room = slots < 0 ? FreeWorkers : Math.Min(FreeWorkers, slots - jobs[(int)job]);
|
|
||||||
if (room <= 0) return 0;
|
|
||||||
SetWorkers(job, jobs[(int)job] + room);
|
|
||||||
return room;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Расставить свободных по всем профессиям поровну (старое сохранение, первый запуск после этапа 3)
|
|
||||||
public void AutoFillAll()
|
public void AutoFillAll()
|
||||||
{
|
{
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
bool progress = true;
|
bool progress = true;
|
||||||
while (FreeWorkers > 0 && progress)
|
int cap = Math.Max(Workers, rules.StartWorkers);
|
||||||
|
while (FreeWorkers > 0 && Workers < cap && progress)
|
||||||
{
|
{
|
||||||
progress = false;
|
progress = false;
|
||||||
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0; j++)
|
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0 && Workers < cap; j++)
|
||||||
{
|
{
|
||||||
int slots = SlotsFor((WorkJob)j);
|
int slots = SlotsFor((WorkJob)j);
|
||||||
if (slots >= 0 && jobs[j] >= slots) continue;
|
if (slots >= 0 && jobs[j] >= slots) continue;
|
||||||
@@ -327,6 +415,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
foodOwed = 0;
|
foodOwed = 0;
|
||||||
lastTickUnix = 0;
|
lastTickUnix = 0;
|
||||||
starving = false;
|
starving = false;
|
||||||
|
inTraining = 0;
|
||||||
|
famine.Clear();
|
||||||
startingKitGiven = false;
|
startingKitGiven = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +438,12 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
sb.Append("food=").Append(foodOwed.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("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||||
sb.Append("starving=").Append(starving ? "1" : "0").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');
|
sb.Append("kit=").Append(startingKitGiven ? "1" : "0").Append('\n');
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
@@ -385,9 +481,30 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
|||||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) lastTickUnix = Math.Max(0, t);
|
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) lastTickUnix = Math.Max(0, t);
|
||||||
break;
|
break;
|
||||||
case "starving": starving = value == "1"; 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;
|
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 после сброса всех модулей.
|
// Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4342ee63e1ed441786fda6c36b2da960
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1986efcb01c943548be8d0a24fbc56c9
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -2,10 +2,9 @@ using UnityEngine;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
||||||
// Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО
|
// Список зданий и сама постройка.
|
||||||
// на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано.
|
// Сейчас стройка свободная, на сетке: игрок сам выбирает место (BuildGrid, BuildGhost, BuildModeController),
|
||||||
// Наведение на пустой слот -> подсветка (можно строить).
|
// а здание ставит BuildAt. Участки (BuildSlot) остались для старых сцен и сохранений -- BuildGrid их прячет.
|
||||||
// Наведение на занятый слот / мимо слотов -> подсветки нет, клик ничего не делает.
|
|
||||||
|
|
||||||
public class BuildManager : MonoBehaviour
|
public class BuildManager : MonoBehaviour
|
||||||
{
|
{
|
||||||
@@ -148,7 +147,47 @@ public class BuildManager : MonoBehaviour
|
|||||||
Debug.Log("[СТРОЙКА] Размещение отменено.");
|
Debug.Log("[СТРОЙКА] Размещение отменено.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Новая схема стройки: тап по участку -> выбор здания (BuildModeController) =====
|
// ===== Свободная стройка на сетке =====
|
||||||
|
|
||||||
|
// Данные здания по имени (имя хранится в 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Старая схема: тап по участку -> выбор здания (участки спрятаны, оставлено для совместимости) =====
|
||||||
|
|
||||||
// Построить здание сразу на указанном участке. true -- получилось.
|
// Построить здание сразу на указанном участке. true -- получилось.
|
||||||
public bool BuildOnSlot(BuildingData data, BuildSlot slot)
|
public bool BuildOnSlot(BuildingData data, BuildSlot slot)
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class BuildingData : ScriptableObject
|
|||||||
[Tooltip("Необходимый уровень ратуши")]
|
[Tooltip("Необходимый уровень ратуши")]
|
||||||
public int requiredTownHallLevel = 1;
|
public int requiredTownHallLevel = 1;
|
||||||
|
|
||||||
|
[Header("Размер на сетке стройки")]
|
||||||
|
[Tooltip("Сколько клеток занимает здание (ширина × высота). 0 × 0 -- посчитать само по коллайдеру префаба")]
|
||||||
|
public Vector2Int footprint;
|
||||||
|
|
||||||
[Header("Стоимость")]
|
[Header("Стоимость")]
|
||||||
public ResourceCost[] cost;
|
public ResourceCost[] cost;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
using System;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
||||||
// Вешается на объект Ратуши, который уже стоит на карте.
|
// Вешается на объект Ратуши, который уже стоит на карте.
|
||||||
// Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager.
|
// Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager.
|
||||||
|
// Тап по Ратуше открывает окно Ратуши (слой приложения подписан на Tapped), улучшение -- кнопкой в окне.
|
||||||
|
|
||||||
public class TownHall : MonoBehaviour
|
public class TownHall : MonoBehaviour
|
||||||
{
|
{
|
||||||
public static TownHall Instance { get; private set; }
|
public static TownHall Instance { get; private set; }
|
||||||
|
|
||||||
|
// Тап по Ратуше: интерфейс откроет её окно (модуль про интерфейс не знает)
|
||||||
|
public static event Action<TownHall> Tapped;
|
||||||
|
|
||||||
[Header("Уровень")]
|
[Header("Уровень")]
|
||||||
public int currentLevel = 1;
|
public int currentLevel = 1;
|
||||||
public int maxLevel = 10;
|
public int maxLevel = 10;
|
||||||
@@ -64,10 +69,18 @@ public class TownHall : MonoBehaviour
|
|||||||
EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel });
|
EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel });
|
||||||
Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!");
|
Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!");
|
||||||
}
|
}
|
||||||
|
Vector3 downPos;
|
||||||
|
|
||||||
void OnMouseDown()
|
void OnMouseDown()
|
||||||
{
|
{
|
||||||
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней
|
downPos = Input.mousePosition;
|
||||||
Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение...");
|
}
|
||||||
Upgrade();
|
|
||||||
|
// Отпустил палец на Ратуше, почти не сдвинув (а не тащил камеру) -- окно Ратуши. Раньше тап сразу улучшал
|
||||||
|
void OnMouseUpAsButton()
|
||||||
|
{
|
||||||
|
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на Ратушу под ней
|
||||||
|
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||||
|
Tapped?.Invoke(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,17 +117,464 @@ public class ModuleTests
|
|||||||
Assert.AreEqual(0, army.SoldiersAtHome + army.TroopsInMarches);
|
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]
|
[Test]
|
||||||
public void Battle_MoreSoldiersWins_TieGoesToDefender()
|
public void Battle_MoreSoldiersWins_TieGoesToDefender()
|
||||||
{
|
{
|
||||||
var battle = new BattleSystem();
|
var battle = new BattleSystem(BattleRules.NoRandom);
|
||||||
Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory);
|
Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory);
|
||||||
Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, 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);
|
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]
|
[Test]
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ public class PopulationTests
|
|||||||
|
|
||||||
Assert.AreEqual(300, p.Total);
|
Assert.AreEqual(300, p.Total);
|
||||||
Assert.AreEqual(270, p.Civilians);
|
Assert.AreEqual(270, p.Civilians);
|
||||||
Assert.AreEqual(120, p.Workforce, "могут работать 120");
|
Assert.AreEqual(270, p.Workforce, "работать может любой житель -- общего ограничения нет (регламент 31.10)");
|
||||||
Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)");
|
Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)");
|
||||||
Assert.AreEqual(120, p.FreeWorkers);
|
Assert.AreEqual(270, p.FreeWorkers);
|
||||||
Assert.AreEqual(30, p.Soldiers);
|
Assert.AreEqual(30, p.Soldiers);
|
||||||
Assert.AreEqual(33f, p.FoodPerHour, 0.001f);
|
Assert.AreEqual(33f, p.FoodPerHour, 0.001f);
|
||||||
|
|
||||||
@@ -118,6 +118,274 @@ public class PopulationTests
|
|||||||
Assert.IsTrue(p.IsStarving);
|
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]
|
[Test]
|
||||||
public void FoodReturns_StarvationEnds_GrowthResumes()
|
public void FoodReturns_StarvationEnds_GrowthResumes()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ public class ProductionTests
|
|||||||
Assert.AreEqual(22.5f, production.RatePerHour(ResourceType.Stone), 0.001f);
|
Assert.AreEqual(22.5f, production.RatePerHour(ResourceType.Stone), 0.001f);
|
||||||
Assert.AreEqual(15f, production.RatePerHour(ResourceType.Iron), 0.001f);
|
Assert.AreEqual(15f, production.RatePerHour(ResourceType.Iron), 0.001f);
|
||||||
Assert.AreEqual(100, population.Workers);
|
Assert.AreEqual(100, population.Workers);
|
||||||
Assert.AreEqual(20, population.FreeWorkers);
|
Assert.AreEqual(170, population.FreeWorkers, "работать может любой из 270 жителей");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -93,9 +93,10 @@ public class ProductionTests
|
|||||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест
|
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест
|
||||||
Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить");
|
Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить");
|
||||||
|
|
||||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Farmer, 1),
|
var farms = new List<KeyValuePair<WorkJob, int>> { new KeyValuePair<WorkJob, int>(WorkJob.Woodcutter, 1) };
|
||||||
(WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
for (int i = 0; i < 9; i++) farms.Add(new KeyValuePair<WorkJob, int>(WorkJob.Farmer, 1)); // 270 мест
|
||||||
Assert.AreEqual(90, population.SetWorkers(WorkJob.Farmer, 120), "свободных осталось 90 из 120");
|
production.SetBuildings(farms);
|
||||||
|
Assert.AreEqual(240, population.SetWorkers(WorkJob.Farmer, 270), "свободных жителей осталось 240 из 270");
|
||||||
Assert.AreEqual(0, population.FreeWorkers);
|
Assert.AreEqual(0, population.FreeWorkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,18 +138,69 @@ public class ProductionTests
|
|||||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter));
|
Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter));
|
||||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter));
|
Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter));
|
||||||
Assert.AreEqual(30, population.FreeWorkers);
|
Assert.AreEqual(180, population.FreeWorkers);
|
||||||
Assert.IsTrue(population.JobsInitialized);
|
Assert.IsTrue(population.JobsInitialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void AutoFill_NewBuilding_TakesFreeWorkers()
|
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)));
|
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
|
||||||
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer));
|
population.AutoFillAll(); // стартовая расстановка -- один раз
|
||||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||||
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer), "вторая ферма -- ещё 30");
|
|
||||||
Assert.AreEqual(60, 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]
|
[Test]
|
||||||
@@ -159,7 +211,7 @@ public class ProductionTests
|
|||||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
|
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
|
||||||
population.ClampJobs();
|
population.ClampJobs();
|
||||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||||
Assert.AreEqual(90, population.FreeWorkers);
|
Assert.AreEqual(240, population.FreeWorkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
public event Action<MarchData> OnBattleResolved;
|
public event Action<MarchData> OnBattleResolved;
|
||||||
public event Action<MarchData> OnMarchReturned;
|
public event Action<MarchData> OnMarchReturned;
|
||||||
|
|
||||||
// Время мира. В мультиплеере -- время сервера, а не телефона
|
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
|
||||||
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
|
||||||
|
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000;
|
||||||
|
|
||||||
private readonly List<WorldEvent> events = new List<WorldEvent>();
|
private readonly List<WorldEvent> events = new List<WorldEvent>();
|
||||||
private WorldSimulation world;
|
private WorldSimulation world;
|
||||||
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
|
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
|
||||||
|
private List<CampState> pendingCamps;
|
||||||
|
|
||||||
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
|
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
|
||||||
public WorldSimulation World
|
public WorldSimulation World
|
||||||
@@ -42,8 +44,11 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
if (world == null)
|
if (world == null)
|
||||||
{
|
{
|
||||||
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>());
|
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>());
|
||||||
|
world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditions() : BattleCondition.None;
|
||||||
if (pendingMarches != null) world.LoadMarches(pendingMarches);
|
if (pendingMarches != null) world.LoadMarches(pendingMarches);
|
||||||
|
if (pendingCamps != null) world.LoadCampStates(pendingCamps);
|
||||||
pendingMarches = null;
|
pendingMarches = null;
|
||||||
|
pendingCamps = null;
|
||||||
}
|
}
|
||||||
return world;
|
return world;
|
||||||
}
|
}
|
||||||
@@ -88,6 +93,16 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
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 ----------------
|
// ---------------- Контракт IWorld ----------------
|
||||||
|
|
||||||
public int ActiveMarches => World.Marches.Count;
|
public int ActiveMarches => World.Marches.Count;
|
||||||
@@ -99,17 +114,26 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
error = "Нет данных о войске -- запусти игру из города";
|
error = "Нет данных о войске -- запусти игру из города";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (army.SoldiersAtHome < soldiers)
|
if (army.AvailableForMarch < soldiers)
|
||||||
{
|
{
|
||||||
error = "Не хватает воинов. Наймите их в Казармах";
|
error = army.GarrisonTotal > 0
|
||||||
|
? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»"
|
||||||
|
: "Не хватает воинов. Наймите их в Казармах";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
|
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
|
||||||
if (march == null) return false;
|
if (march == null) return false;
|
||||||
|
|
||||||
// Ушедшие воины недоступны дома, пока не вернутся
|
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
|
||||||
army.TakeForMarch(march.id, soldiers);
|
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
|
||||||
|
if (!army.TakeForMarch(march.id, soldiers))
|
||||||
|
{
|
||||||
|
World.CancelMarch(march.id);
|
||||||
|
error = "Не удалось собрать войско в поход";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
march.units = army.GetMarchUnits(march.id); // состав похода -- для боя с типами войск
|
||||||
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
||||||
|
|
||||||
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
|
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
|
||||||
@@ -121,7 +145,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
void HandleBattleResolved(MarchData march)
|
void HandleBattleResolved(MarchData march)
|
||||||
{
|
{
|
||||||
MapNode target = World.GetNode(march.targetNodeId);
|
MapNode target = World.GetNode(march.targetNodeId);
|
||||||
Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
|
if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя");
|
||||||
|
else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
|
||||||
$"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}.");
|
$"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}.");
|
||||||
OnBattleResolved?.Invoke(march);
|
OnBattleResolved?.Invoke(march);
|
||||||
EventBus.Publish(new MarchBattleResolvedEvent
|
EventBus.Publish(new MarchBattleResolvedEvent
|
||||||
@@ -137,7 +162,17 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
// Солдаты и добыча зачисляются, только когда войско дошло до дома
|
// Солдаты и добыча зачисляются, только когда войско дошло до дома
|
||||||
void HandleMarchReturned(MarchData march)
|
void HandleMarchReturned(MarchData march)
|
||||||
{
|
{
|
||||||
GameServices.Get<IArmy>().ReturnFromMarch(march.id, march.survivors);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
IEconomy economy = GameServices.Get<IEconomy>();
|
IEconomy economy = GameServices.Get<IEconomy>();
|
||||||
foreach (var reward in march.loot)
|
foreach (var reward in march.loot)
|
||||||
@@ -156,6 +191,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
Victory = march.victory,
|
Victory = march.victory,
|
||||||
Survivors = march.survivors,
|
Survivors = march.survivors,
|
||||||
Sent = march.soldiers,
|
Sent = march.soldiers,
|
||||||
|
TargetEmpty = march.targetEmpty,
|
||||||
TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "",
|
TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "",
|
||||||
LootText = FormatLoot(march.loot)
|
LootText = FormatLoot(march.loot)
|
||||||
});
|
});
|
||||||
@@ -167,25 +203,35 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
|||||||
class WorldSaveData
|
class WorldSaveData
|
||||||
{
|
{
|
||||||
public List<MarchData> marches = new List<MarchData>();
|
public List<MarchData> marches = new List<MarchData>();
|
||||||
|
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
|
||||||
}
|
}
|
||||||
|
|
||||||
public string SaveKey => "world";
|
public string SaveKey => "world";
|
||||||
|
|
||||||
public string Capture()
|
public string Capture()
|
||||||
{
|
{
|
||||||
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave() });
|
var camps = world != null ? world.GetCampStates() : new List<CampState>(pendingCamps ?? new List<CampState>());
|
||||||
|
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps });
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Restore(string json)
|
public void Restore(string json)
|
||||||
{
|
{
|
||||||
LoadMarches(JsonUtility.FromJson<WorldSaveData>(json)?.marches);
|
WorldSaveData data = JsonUtility.FromJson<WorldSaveData>(json);
|
||||||
|
LoadMarches(data?.marches);
|
||||||
|
if (world != null) world.LoadCampStates(data?.camps);
|
||||||
|
else pendingCamps = data?.camps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом)
|
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте
|
||||||
public void ResetState()
|
public void ResetState()
|
||||||
{
|
{
|
||||||
pendingMarches = null;
|
pendingMarches = null;
|
||||||
if (world != null) world.LoadMarches(new List<MarchData>());
|
pendingCamps = null;
|
||||||
|
if (world != null)
|
||||||
|
{
|
||||||
|
world.LoadMarches(new List<MarchData>());
|
||||||
|
world.LoadCampStates(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<MarchData> GetMarchesForSave()
|
public List<MarchData> GetMarchesForSave()
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ namespace Bogatyri.World
|
|||||||
{
|
{
|
||||||
PlayerCity, // город игрока
|
PlayerCity, // город игрока
|
||||||
NeighborCity, // соседний город -- другой игрок
|
NeighborCity, // соседний город -- другой игрок
|
||||||
BossCastle // Большой замок с боссом в центре карты
|
BossCastle, // Большой замок с боссом в центре карты
|
||||||
|
Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова
|
||||||
}
|
}
|
||||||
|
|
||||||
[Serializable]
|
[Serializable]
|
||||||
@@ -31,6 +32,7 @@ namespace Bogatyri.World
|
|||||||
|
|
||||||
public bool canBeAttacked;
|
public bool canBeAttacked;
|
||||||
public int garrison; // солдат в обороне
|
public int garrison; // солдат в обороне
|
||||||
|
public int level; // уровень лагеря (1..3), у остальных точек 0
|
||||||
public List<RewardEntry> rewards = new List<RewardEntry>();
|
public List<RewardEntry> rewards = new List<RewardEntry>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +56,22 @@ namespace Bogatyri.World
|
|||||||
const int MinCitySpacing = 3600; // минимальное расстояние между городами
|
const int MinCitySpacing = 3600; // минимальное расстояние между городами
|
||||||
const int MaxPlacementAttempts = 1000;
|
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 =
|
static readonly string[] NeighborNames =
|
||||||
{
|
{
|
||||||
@@ -128,11 +146,45 @@ namespace Bogatyri.World
|
|||||||
nodes.Add(city);
|
nodes.Add(city);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AddCamps(nodes, new DeterministicRandom(seed ^ CampSeedSalt), CampCount);
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Случайная точка, не слишком близко к замку и к другим городам
|
static void AddCamps(List<MapNode> nodes, DeterministicRandom rng, int count)
|
||||||
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y)
|
{
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
|
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
|
||||||
{
|
{
|
||||||
@@ -142,7 +194,7 @@ namespace Bogatyri.World
|
|||||||
bool free = true;
|
bool free = true;
|
||||||
foreach (var n in nodes)
|
foreach (var n in nodes)
|
||||||
{
|
{
|
||||||
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : MinCitySpacing;
|
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : spacing;
|
||||||
if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance)
|
if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance)
|
||||||
{
|
{
|
||||||
free = false;
|
free = false;
|
||||||
@@ -157,6 +209,47 @@ namespace Bogatyri.World
|
|||||||
return false;
|
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)
|
public static MapNode Find(List<MapNode> nodes, string id)
|
||||||
{
|
{
|
||||||
foreach (var n in nodes)
|
foreach (var n in nodes)
|
||||||
|
|||||||
@@ -23,8 +23,19 @@ namespace Bogatyri.World
|
|||||||
public int soldiers; // сколько ушло
|
public int soldiers; // сколько ушло
|
||||||
public int survivors; // сколько возвращается (известно после боя)
|
public int survivors; // сколько возвращается (известно после боя)
|
||||||
public bool victory;
|
public bool victory;
|
||||||
public int defenders; // гарнизон цели в момент боя
|
public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются
|
||||||
|
public int defenders; // гарнизон цели в момент боя
|
||||||
public int defenderLosses; // сколько потерял защитник
|
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 List<RewardEntry> loot = new List<RewardEntry>();
|
||||||
|
|
||||||
public long departMs;
|
public long departMs;
|
||||||
@@ -45,6 +56,20 @@ 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
|
public enum WorldEventType
|
||||||
{
|
{
|
||||||
BattleResolved, // войско дошло, бой прошёл
|
BattleResolved, // войско дошло, бой прошёл
|
||||||
@@ -64,6 +89,8 @@ namespace Bogatyri.World
|
|||||||
private readonly List<MapNode> nodes;
|
private readonly List<MapNode> nodes;
|
||||||
private readonly IBattleSystem battleSystem;
|
private readonly IBattleSystem battleSystem;
|
||||||
private readonly List<MarchData> marches = new List<MarchData>();
|
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;
|
private int nextMarchId = 1;
|
||||||
|
|
||||||
public IReadOnlyList<MapNode> Nodes => nodes;
|
public IReadOnlyList<MapNode> Nodes => nodes;
|
||||||
@@ -76,6 +103,11 @@ namespace Bogatyri.World
|
|||||||
this.battleSystem = battleSystem;
|
this.battleSystem = battleSystem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Состояние войска нападающего на момент боя (голод в его поселении, усталость).
|
||||||
|
// Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения,
|
||||||
|
// сервер -- по своим данным). Не задано -- без штрафов.
|
||||||
|
public Func<MarchData, BattleCondition> AttackerConditions { get; set; }
|
||||||
|
|
||||||
public MapNode GetNode(string id)
|
public MapNode GetNode(string id)
|
||||||
{
|
{
|
||||||
return WorldMapLayout.Find(nodes, id);
|
return WorldMapLayout.Find(nodes, id);
|
||||||
@@ -97,6 +129,36 @@ 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)
|
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error)
|
||||||
{
|
{
|
||||||
MapNode from = GetNode(fromId);
|
MapNode from = GetNode(fromId);
|
||||||
@@ -104,6 +166,11 @@ namespace Bogatyri.World
|
|||||||
|
|
||||||
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
|
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
|
||||||
if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; 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; }
|
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
|
||||||
|
|
||||||
var march = new MarchData
|
var march = new MarchData
|
||||||
@@ -118,24 +185,42 @@ namespace Bogatyri.World
|
|||||||
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
|
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
|
||||||
state = MarchState.Outbound
|
state = MarchState.Outbound
|
||||||
};
|
};
|
||||||
|
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
|
||||||
|
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
|
||||||
|
march.battleSeed = BattleSeed(march.id, nowMs, targetId);
|
||||||
marches.Add(march);
|
marches.Add(march);
|
||||||
|
|
||||||
error = null;
|
error = null;
|
||||||
return march;
|
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. Если игра была закрыта долго, за один вызов поход
|
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
|
||||||
// может и дойти, и вернуться -- события придут в правильном порядке.
|
// может и дойти, и вернуться -- события придут в правильном порядке.
|
||||||
public void Advance(long nowMs, List<WorldEvent> events)
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var m in marches)
|
foreach (var m in marches)
|
||||||
{
|
{
|
||||||
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs)
|
|
||||||
{
|
|
||||||
ResolveBattle(m);
|
|
||||||
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
|
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
|
||||||
{
|
{
|
||||||
m.state = MarchState.Finished;
|
m.state = MarchState.Finished;
|
||||||
@@ -144,30 +229,89 @@ namespace Bogatyri.World
|
|||||||
}
|
}
|
||||||
|
|
||||||
marches.RemoveAll(m => m.state == MarchState.Finished);
|
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)
|
void ResolveBattle(MarchData m)
|
||||||
{
|
{
|
||||||
MapNode from = GetNode(m.fromNodeId);
|
MapNode from = GetNode(m.fromNodeId);
|
||||||
MapNode target = GetNode(m.targetNodeId);
|
MapNode target = GetNode(m.targetNodeId);
|
||||||
|
|
||||||
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { Attackers = m.soldiers, Defenders = target.garrison });
|
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
|
||||||
|
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;
|
||||||
m.victory = outcome.Victory;
|
m.victory = outcome.Victory;
|
||||||
m.survivors = outcome.Survivors;
|
m.survivors = outcome.Survivors;
|
||||||
|
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
|
||||||
m.defenders = target.garrison;
|
m.defenders = target.garrison;
|
||||||
m.defenderLosses = outcome.DefenderLosses;
|
m.defenderLosses = outcome.DefenderLosses;
|
||||||
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
|
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
|
||||||
// их потери будет списывать сервер из армии того игрока.
|
// их потери будет списывать сервер из армии того игрока.
|
||||||
m.loot = new List<RewardEntry>();
|
|
||||||
if (outcome.Victory)
|
if (outcome.Victory)
|
||||||
{
|
{
|
||||||
foreach (var r in target.rewards)
|
foreach (var r in target.rewards)
|
||||||
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
|
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
|
||||||
}
|
|
||||||
|
|
||||||
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
|
// Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя
|
||||||
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
|
if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs;
|
||||||
m.state = MarchState.Returning;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Номер боя из данных похода (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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public static class MarchReturnPopup
|
|||||||
|
|
||||||
MarchReturnedEvent e = queue.Dequeue();
|
MarchReturnedEvent e = queue.Dequeue();
|
||||||
string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»";
|
string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»";
|
||||||
string result = e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
|
string result = e.TargetEmpty ? "лагерь уже был разбит, боя не было"
|
||||||
|
: e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
|
||||||
int lost = Mathf.Max(0, e.Sent - e.Survivors);
|
int lost = Mathf.Max(0, e.Sent - e.Survivors);
|
||||||
|
|
||||||
string text = $"{target}: {result}\n" +
|
string text = $"{target}: {result}\n" +
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ using UnityEngine;
|
|||||||
|
|
||||||
// Подгоняет RectTransform под безопасную зону экрана (Screen.safeArea): вырез камеры, скруглённые углы,
|
// Подгоняет RectTransform под безопасную зону экрана (Screen.safeArea): вырез камеры, скруглённые углы,
|
||||||
// полоска "домой" на iPhone. Следит за поворотом и сменой разрешения (и в Device Simulator).
|
// полоска "домой" на iPhone. Следит за поворотом и сменой разрешения (и в Device Simulator).
|
||||||
// Вешать на растянутый на весь Canvas объект; всё, что внутри, не залезет под вырез.
|
// Вешать на растянутый объект; всё, что внутри, не залезет под вырез.
|
||||||
|
// - fitInsideParent = false: объект растянут на весь Canvas (HUD);
|
||||||
|
// - fitInsideParent = true: родитель не на весь экран (окно между верхней и нижней панелью) --
|
||||||
|
// зона пересчитывается в координаты родителя, отступ появляется только там, где родитель заходит под вырез.
|
||||||
[RequireComponent(typeof(RectTransform))]
|
[RequireComponent(typeof(RectTransform))]
|
||||||
public class SafeArea : MonoBehaviour
|
public class SafeArea : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
public bool fitInsideParent;
|
||||||
|
|
||||||
private RectTransform rect;
|
private RectTransform rect;
|
||||||
private Rect appliedArea;
|
private Rect appliedArea;
|
||||||
private Vector2Int appliedScreen;
|
private Vector2Int appliedScreen;
|
||||||
|
private Rect appliedParent;
|
||||||
|
|
||||||
void Awake()
|
void Awake()
|
||||||
{
|
{
|
||||||
@@ -18,7 +24,8 @@ public class SafeArea : MonoBehaviour
|
|||||||
|
|
||||||
void Update()
|
void Update()
|
||||||
{
|
{
|
||||||
if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y)
|
if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y
|
||||||
|
|| (fitInsideParent && ParentScreenRect() != appliedParent))
|
||||||
Apply();
|
Apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +36,41 @@ public class SafeArea : MonoBehaviour
|
|||||||
appliedScreen = new Vector2Int(Screen.width, Screen.height);
|
appliedScreen = new Vector2Int(Screen.width, Screen.height);
|
||||||
if (Screen.width <= 0 || Screen.height <= 0) return;
|
if (Screen.width <= 0 || Screen.height <= 0) return;
|
||||||
|
|
||||||
// Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком
|
if (!fitInsideParent)
|
||||||
rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height);
|
{
|
||||||
rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
rect.offsetMin = Vector2.zero;
|
rect.offsetMin = Vector2.zero;
|
||||||
rect.offsetMax = 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,15 +44,18 @@ public class WorldMapController : MonoBehaviour
|
|||||||
public TMP_Text popupBodyText;
|
public TMP_Text popupBodyText;
|
||||||
public Button popupOkButton;
|
public Button popupOkButton;
|
||||||
|
|
||||||
const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать";
|
const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать";
|
||||||
const float CastleSize = 2.2f;
|
const float CastleSize = 2.2f;
|
||||||
const float CitySize = 1.1f;
|
const float CitySize = 1.1f;
|
||||||
|
const float CampSize = 0.8f;
|
||||||
const float StartZoom = 8f;
|
const float StartZoom = 8f;
|
||||||
|
|
||||||
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
|
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 CastleColor = new Color(0.55f, 0.15f, 0.15f);
|
||||||
static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f);
|
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 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 Transform mapRoot;
|
||||||
private readonly Dictionary<int, MarchView> marchViews = new Dictionary<int, MarchView>();
|
private readonly Dictionary<int, MarchView> marchViews = new Dictionary<int, MarchView>();
|
||||||
@@ -60,6 +63,14 @@ public class WorldMapController : MonoBehaviour
|
|||||||
private readonly List<int> staleMarchIds = new List<int>();
|
private readonly List<int> staleMarchIds = new List<int>();
|
||||||
private MapNode selectedTarget;
|
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;
|
WorldSimulation World => MarchManager.Instance.World;
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
@@ -107,8 +118,13 @@ public class WorldMapController : MonoBehaviour
|
|||||||
{
|
{
|
||||||
HandleMapTap();
|
HandleMapTap();
|
||||||
SyncMarchViews();
|
SyncMarchViews();
|
||||||
|
RefreshCamps();
|
||||||
|
RefreshOpenAttackPanel();
|
||||||
|
|
||||||
soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}";
|
// Воины, а не отряды: "В походе: 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})";
|
||||||
}
|
}
|
||||||
|
|
||||||
void HookUpComingSoon(Button button, string sectionName)
|
void HookUpComingSoon(Button button, string sectionName)
|
||||||
@@ -149,6 +165,13 @@ public class WorldMapController : MonoBehaviour
|
|||||||
CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0);
|
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);
|
CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f);
|
||||||
break;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +210,7 @@ public class WorldMapController : MonoBehaviour
|
|||||||
return sr;
|
return sr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CreateLabel(string text, Vector3 position, float fontSize)
|
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
|
||||||
{
|
{
|
||||||
var go = new GameObject("Label");
|
var go = new GameObject("Label");
|
||||||
go.transform.SetParent(mapRoot, false);
|
go.transform.SetParent(mapRoot, false);
|
||||||
@@ -198,6 +221,29 @@ public class WorldMapController : MonoBehaviour
|
|||||||
tmp.alignment = TextAlignmentOptions.Center;
|
tmp.alignment = TextAlignmentOptions.Center;
|
||||||
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
|
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
|
||||||
tmp.sortingOrder = 10;
|
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)
|
public static Vector3 ToWorld(MapNode node)
|
||||||
@@ -217,6 +263,8 @@ public class WorldMapController : MonoBehaviour
|
|||||||
if (node == null) return;
|
if (node == null) return;
|
||||||
|
|
||||||
if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него";
|
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);
|
else if (node.canBeAttacked) OpenAttackPanel(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +273,8 @@ public class WorldMapController : MonoBehaviour
|
|||||||
foreach (var node in World.Nodes)
|
foreach (var node in World.Nodes)
|
||||||
{
|
{
|
||||||
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
|
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
|
||||||
float halfSize = (node.type == MapNodeType.BossCastle ? CastleSize : CitySize) / 2f + 0.2f;
|
float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize;
|
||||||
|
float halfSize = size / 2f + 0.2f;
|
||||||
Vector3 pos = ToWorld(node);
|
Vector3 pos = ToWorld(node);
|
||||||
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
|
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
|
||||||
}
|
}
|
||||||
@@ -241,12 +290,17 @@ public class WorldMapController : MonoBehaviour
|
|||||||
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
|
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
|
||||||
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
|
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
|
||||||
|
|
||||||
attackTitleText.text = target.type == MapNodeType.NeighborCity
|
attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}"
|
||||||
? $"Город игрока {target.displayName}"
|
: target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}"
|
||||||
: target.displayName;
|
: target.displayName;
|
||||||
attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" +
|
attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает
|
||||||
$"Путь в одну сторону: {travelSeconds} сек\n" +
|
attackInfoText.fontSizeMin = 18;
|
||||||
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
attackInfoText.fontSizeMax = 32;
|
||||||
|
attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" +
|
||||||
|
$"Путь в одну сторону: {travelSeconds} сек" +
|
||||||
|
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
|
||||||
|
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
||||||
|
RefreshAttackConditions();
|
||||||
|
|
||||||
soldiersSlider.minValue = available > 0 ? 1 : 0;
|
soldiersSlider.minValue = available > 0 ? 1 : 0;
|
||||||
soldiersSlider.maxValue = available;
|
soldiersSlider.maxValue = available;
|
||||||
@@ -259,12 +313,90 @@ public class WorldMapController : MonoBehaviour
|
|||||||
attackPanel.SetActive(true);
|
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()
|
void UpdateSoldiersToSendText()
|
||||||
{
|
{
|
||||||
int available = GetSoldiersAtHome();
|
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
|
soldiersToSendText.text = available > 0
|
||||||
? $"Отправить: {(int)soldiersSlider.value} из {available}"
|
? $"Отправить: {(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]}";
|
||||||
}
|
}
|
||||||
|
|
||||||
void CloseAttackPanel()
|
void CloseAttackPanel()
|
||||||
@@ -292,7 +424,7 @@ public class WorldMapController : MonoBehaviour
|
|||||||
static int GetSoldiersAtHome()
|
static int GetSoldiersAtHome()
|
||||||
{
|
{
|
||||||
return ResourceManager.Instance != null
|
return ResourceManager.Instance != null
|
||||||
? GameServices.Get<IArmy>().SoldiersAtHome // [объединение] наши воины всех типов
|
? GameServices.Get<IArmy>().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,10 +467,21 @@ public class WorldMapController : MonoBehaviour
|
|||||||
void HandleBattleResolved(MarchData march)
|
void HandleBattleResolved(MarchData march)
|
||||||
{
|
{
|
||||||
MapNode target = World.GetNode(march.targetNodeId);
|
MapNode target = World.GetNode(march.targetNodeId);
|
||||||
|
if (march.targetEmpty)
|
||||||
|
{
|
||||||
|
EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
|
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
|
||||||
string body = $"{target.displayName}\n" +
|
string body = $"{target.displayName}\n" +
|
||||||
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
|
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
|
||||||
$"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\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";
|
$"Домой идут: {march.survivors}\n";
|
||||||
body += march.victory
|
body += march.victory
|
||||||
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
|
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
|
||||||
@@ -370,6 +513,11 @@ public class WorldMapController : MonoBehaviour
|
|||||||
var (title, body) = popupQueue.Dequeue();
|
var (title, body) = popupQueue.Dequeue();
|
||||||
popupTitleText.text = title;
|
popupTitleText.text = title;
|
||||||
popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f);
|
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;
|
popupBodyText.text = body;
|
||||||
popupPanel.SetActive(true);
|
popupPanel.SetActive(true);
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-7
@@ -1,6 +1,6 @@
|
|||||||
# Армия
|
# Армия
|
||||||
|
|
||||||
Войска и их характеристики: сколько воинов каждого типа в городе, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
|
Войска и их характеристики: сколько воинов каждого типа в городе, сколько из них в гарнизоне, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
|
||||||
|
|
||||||
- **Сборка:** `Bogatyri.Army` (без Unity).
|
- **Сборка:** `Bogatyri.Army` (без Unity).
|
||||||
- **Папка:** `Assets/Scripts/Modules/Army`.
|
- **Папка:** `Assets/Scripts/Modules/Army`.
|
||||||
@@ -12,12 +12,17 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). |
|
| `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). |
|
||||||
| `GetTroops(type)` | Воинов этого типа в городе. |
|
| `GetTroops(type)` | Воинов этого типа в городе. |
|
||||||
| `SoldiersAtHome` | Всего воинов в городе. |
|
| `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). |
|
||||||
| `TroopsInMarches` | Воинов в походах. |
|
| `TroopsInMarches` | Воинов в походах. |
|
||||||
|
| `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. |
|
||||||
|
| `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. |
|
||||||
|
| `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. |
|
||||||
| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). |
|
| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). |
|
||||||
| `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. |
|
| `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. |
|
||||||
| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. |
|
| `TakeForMarch(marchId, count)` | Забрать свободных воинов (без гарнизона) в поход пропорционально составу и запомнить состав. `false` — ничего не взято: не хватает воинов или поход с таким номером уже есть. |
|
||||||
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. |
|
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. |
|
||||||
|
| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. |
|
||||||
|
| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. |
|
||||||
|
|
||||||
Получить: `GameServices.Get<IArmy>()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт.
|
Получить: `GameServices.Get<IArmy>()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт.
|
||||||
|
|
||||||
@@ -25,9 +30,26 @@
|
|||||||
|
|
||||||
Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `ArmyService_MarchNeverCreatesOrLosesSoldiers` проверяет, что «дома + в походах» всегда равно общему числу.
|
Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `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.
|
||||||
- **Слушает:** ничего.
|
- **Слушает:** ничего.
|
||||||
|
|
||||||
## Сохранение
|
## Сохранение
|
||||||
@@ -36,10 +58,13 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||||
|
garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops)
|
||||||
march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||||
```
|
```
|
||||||
|
|
||||||
Сброс (`ResetState`): войск и походов нет.
|
Старое сохранение без строки `garrison` читается: гарнизона нет.
|
||||||
|
|
||||||
|
Сброс (`ResetState`): войск, гарнизона и походов нет.
|
||||||
|
|
||||||
**Перенос из старых сохранений:**
|
**Перенос из старых сохранений:**
|
||||||
- Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`.
|
- Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`.
|
||||||
@@ -62,13 +87,17 @@ march=<id похода>:<пехота>,<лучники>,<кавалерия>,<о
|
|||||||
- общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**;
|
- общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**;
|
||||||
- гарнизон ест еду и участвует в защите;
|
- гарнизон ест еду и участвует в защите;
|
||||||
- при голоде дебаффы действуют и на гарнизон, и на походы.
|
- при голоде дебаффы действуют и на гарнизон, и на походы.
|
||||||
- **Следующий шаг модуля:** гарнизон — третье место для солдата рядом с «дома» и «в походе».
|
- **Сделано 27.10.2026:** гарнизон города (`SetGarrison`, `AvailableForMarch`).
|
||||||
|
- **Дальше по гарнизону:** участие в защите города (когда на него смогут напасть — PvP), еда гарнизона (сейчас все воины едят одинаково, считает модуль Населения), дебаффы при голоде. Выбор состава по типам — если понадобится.
|
||||||
- Описания типов войск (`GameData.Troops`: цена, сила, время обучения) пока в слое приложения; цены перейдут в `EconomyConfig`.
|
- Описания типов войск (`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` удалён — контракты и перенос сохранений проверены тестами. |
|
| 23.10.2026 | Claude | Войска переехали в модуль: `ArmyService` — единственное хранилище войск (секция `"army"`), `IArmy` расширен (типы, обучение, потери, войска в походах), событие `TroopsChangedEvent`. Перенос из `progress.json` с удалением старых данных только после записи `save.json`. `ArmyBridge` удалён — контракты и перенос сохранений проверены тестами. |
|
||||||
| 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). |
|
| 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). |
|
||||||
| 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. |
|
| 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. |
|
||||||
|
|||||||
+96
-13
@@ -1,28 +1,87 @@
|
|||||||
# Боевая система
|
# Боевая система
|
||||||
|
|
||||||
Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули.
|
Урон, потери, исход боя с учётом типов войск. Модуль только считает: кто победил и сколько погибло каждого типа. Кто идёт в бой и что делать с итогом, решают другие модули.
|
||||||
|
|
||||||
- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере).
|
- **Сборка:** `Bogatyri.Battle` (без Unity и без float — тот же код будет считать бои на сервере).
|
||||||
- **Папка:** `Assets/Scripts/Modules/Battle`.
|
- **Папка:** `Assets/Scripts/Modules/Battle`.
|
||||||
- **Зависит от:** ядра.
|
- **Зависит от:** ядра.
|
||||||
|
|
||||||
## Контракт `IBattleSystem`
|
## Контракт `IBattleSystem`
|
||||||
|
|
||||||
`BattleOutcome Resolve(BattleRequest request)`
|
- `BattleOutcome Resolve(BattleRequest request)` — бой.
|
||||||
|
- `BattleForecast Forecast(BattleRequest request)` — прогноз до боя с учётом случайности. Итог конкретного боя не раскрывает.
|
||||||
|
|
||||||
| `BattleRequest` | `BattleOutcome` |
|
| `BattleRequest` | Что это |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Attackers` — атакующих | `Victory` — победил атакующий |
|
| `AttackerUnits`, `DefenderUnits` | Состав сторон по типам (`TroopType`: пехота, лучники, кавалерия, осадные). |
|
||||||
| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` |
|
| `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. |
|
||||||
|
| `DefenderFortified` | Защитники за стенами (город, замок). У лагерей разбойников стен нет — бой в поле. |
|
||||||
|
| `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). |
|
||||||
|
| `AttackerConditions`, `DefenderConditions` | Состояния сторон (`BattleCondition`, флаги): `Famine` — голод, `Fatigue` — усталость. |
|
||||||
|
| `Seed` | Номер боя. Из него считается случайность сторон: тот же номер с теми же войсками — всегда тот же итог. |
|
||||||
|
|
||||||
Регистрирует `App/GameModules`. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
|
| `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` | Выбывшие защитники по типам — для старого кода. |
|
||||||
|
|
||||||
## Текущие правила
|
| `BattleForecast` | Что это |
|
||||||
|
| --- | --- |
|
||||||
|
| `Chance` | `Advantage` — победа при любой случайности, `Uncertain` — решает случайность, `HighRisk` — поражение при любой случайности. |
|
||||||
|
| `AttackerPowerMin/Max`, `DefenderPowerMin/Max` | Сила сторон при крайних значениях случайности. |
|
||||||
|
| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего. |
|
||||||
|
|
||||||
- Побеждает тот, у кого больше солдат; ничья — победа защитника.
|
Регистрирует `App/GameModules`: числа боя — из таблицы `BattleConfig`, сила воина каждого типа — из `GameData.Troops` (4 / 5 / 9 / 16). Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
|
||||||
- Проигравший теряет половину своего войска, победитель — столько же.
|
|
||||||
|
|
||||||
Пример: 30 против 20 — победа, погибает 10 атакующих и 10 защитников, домой идут 20.
|
## Правила
|
||||||
|
|
||||||
|
Числа — **стартовые для тестирования** (план от 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 вернувшихся.
|
||||||
|
|
||||||
## События
|
## События
|
||||||
|
|
||||||
@@ -34,11 +93,35 @@
|
|||||||
|
|
||||||
## Решения и план
|
## Решения и план
|
||||||
|
|
||||||
- **Этап 6:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики.
|
- **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения».
|
||||||
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`.
|
- **Открыто: прогноз.** «Исход неясен» охватывает и 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`).
|
||||||
|
- **Гарнизоны соседей** пока не уменьшаются после боя: соседи — заглушки до сервера.
|
||||||
|
|
||||||
## Журнал изменений
|
## Журнал изменений
|
||||||
|
|
||||||
| Дата | Кто | Что |
|
| Дата | Кто | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 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` перенесён в ядро. Правила не изменились. |
|
| 19.10.2026 | Claude | Модуль выделен: `BattleResolver` из `WorldSimulation.cs` стал `BattleSystem` с контрактом `IBattleSystem`; `BattleOutcome` перенесён в ядро. Правила не изменились. |
|
||||||
|
|||||||
+11
-2
@@ -31,10 +31,10 @@
|
|||||||
| `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. |
|
| `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. |
|
||||||
| `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. |
|
| `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. |
|
||||||
| `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. |
|
| `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. |
|
||||||
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. |
|
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. |
|
||||||
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
|
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
|
||||||
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
|
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
|
||||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. |
|
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. |
|
||||||
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
|
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
|
||||||
| `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. |
|
| `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. |
|
||||||
| `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). |
|
| `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). |
|
||||||
@@ -58,6 +58,15 @@
|
|||||||
|
|
||||||
| Дата | Кто | Что |
|
| Дата | Кто | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 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`. |
|
| 27.10.2026 | Claude | `WorkJob`; контракт `IProduction`; `IEconomy.GetCapacity`; `IPopulation`: `Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`; событие `WorkersChangedEvent`. |
|
||||||
| 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. |
|
| 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. |
|
||||||
| 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. |
|
| 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. |
|
||||||
|
|||||||
@@ -43,13 +43,15 @@
|
|||||||
## Добыча через рабочих (этап 3)
|
## Добыча через рабочих (этап 3)
|
||||||
|
|
||||||
- **Ресурс производят люди, здание даёт места и эффективность.** Добыча в час = min(рабочие, места) × норма × эффективность.
|
- **Ресурс производят люди, здание даёт места и эффективность.** Добыча в час = min(рабочие, места) × норма × эффективность.
|
||||||
|
- **Голод** (регламент 31.10, ТЕСТ): пока город голодает (`IPopulation.IsStarving`), добыча всего, кроме еды, ×0.5 (`EconomyConfig`, `famineNonFoodMultiplier`). Еда добывается без штрафа, штраф снимается сразу, как только город снова сыт.
|
||||||
- **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник).
|
- **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник).
|
||||||
- **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено.
|
- **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено.
|
||||||
- **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен.
|
- **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен.
|
||||||
- **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья.
|
- **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья.
|
||||||
- **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6).
|
- **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6).
|
||||||
- **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн.
|
- **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн.
|
||||||
- **Новые места заполняются сами:** построили или улучшили здание — свободные рабочие встают туда. Старое сохранение при первом запуске — рабочие расставляются поровну по всем местам.
|
- **Новые места сами не заполняются** (регламент 31.10): построили или улучшили здание — места пустые, пока игрок не поставит рабочих в окне здания. Автоматически — только стартовая расстановка один раз (новая игра или первая загрузка старого сохранения): до 120 рабочих поровну по местам.
|
||||||
|
- **Голод в офлайне** считается по тем же шагам, что и в игре: Население досчитывает время кусками по 10 минут, и в каждом куске добыча берёт голод на этот момент.
|
||||||
- **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час.
|
- **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час.
|
||||||
|
|
||||||
## События
|
## События
|
||||||
@@ -107,6 +109,8 @@
|
|||||||
|
|
||||||
| Дата | Кто | Что |
|
| Дата | Кто | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 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` «Люди» на ячейках панели. |
|
| 27.10.2026 | Claude | Этапы 1 и 3: лимиты склада (`GetCapacity`, Склад, Амбар), «запас / лимит» и прирост на панели; добыча через рабочих (`Bogatyri.Economy.Rules`, `IProduction`), кликер убран, окно здания и рабочих, улучшение зданий. Исправлены лишние `ResourceUIText` «Люди» на ячейках панели. |
|
||||||
| 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. |
|
| 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. |
|
||||||
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. |
|
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. |
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
# Население
|
# Население
|
||||||
|
|
||||||
Сколько в городе людей, сколько они едят и как прирастают. Этап 2 плана разработки.
|
Люди города: жители, рабочие, прирост, еда, голод, найм в войско и демографические ограничения. Самостоятельный модуль (регламент 28.10 и 31.10.2026). С Поселением (здания, Ратуша) связан только через контракт ядра `ISettlement`. Этап 2 плана разработки.
|
||||||
|
|
||||||
|
- **Ответственный:** Валдемар.
|
||||||
- **Сборка:** `Bogatyri.Population` (без Unity).
|
- **Сборка:** `Bogatyri.Population` (без Unity).
|
||||||
- **Папка:** `Assets/Scripts/Modules/Population`.
|
- **Папка:** `Assets/Scripts/Modules/Population`.
|
||||||
- **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`.
|
- **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`.
|
||||||
|
|
||||||
## Как устроено
|
## Как устроено
|
||||||
|
|
||||||
- **Люди города = жители + воины.** Жителей хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
|
- **Люди города = жители + новобранцы в Казармах + воины.** Жителей и новобранцев хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
|
||||||
- **Жители:** способные работать (`Workforce` — 120 из 270 в стартовом городе, доля сохраняется при росте) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`).
|
- **Жители:** работать может любой житель — общего ограничения нет (регламент 31.10). Рабочих ограничивают свободные жители и рабочие места в зданиях. Рабочие стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота), остальные свободны. Стартовый город: 120 рабочих из 270 жителей — при первой расстановке (`AutoFillAll`) больше `StartWorkers` (120) не ставится.
|
||||||
- **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить.
|
- **Найм воинов** (решение команды от 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 еды в час.
|
- **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час.
|
||||||
- **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья.
|
- **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья.
|
||||||
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Что ещё происходит при голоде, команда пока не решила.
|
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Добыча всего, кроме еды, ×0.5 (модуль Экономики, регламент 31.10, ТЕСТ), еда добывается без штрафа. Бой ослабляет голодное войско (модуль Боя, −50%). Штраф снимается сразу, как только город снова сыт. Смерти от голода нет.
|
||||||
|
- **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру.
|
||||||
- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены.
|
- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены.
|
||||||
- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали.
|
- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали.
|
||||||
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
|
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
|
||||||
@@ -31,6 +35,12 @@
|
|||||||
| `HousingCap` | Предел населения. |
|
| `HousingCap` | Предел населения. |
|
||||||
| `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. |
|
| `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. |
|
||||||
| `IsStarving` | Еды не хватило на последний расчёт. |
|
| `IsStarving` | Еды не хватило на последний расчёт. |
|
||||||
|
| `CivilianReserve` | Гражданский резерв: столько свободных жителей в войско не взять. |
|
||||||
|
| `Recruitable` | Сколько воинов можно нанять прямо сейчас: (свободные − резерв) / жителей на воина. |
|
||||||
|
| `InTraining` | Людей, взятых из жителей и ещё обучающихся в Казармах. |
|
||||||
|
| `TryRecruit(n)`, `RecruitsTrained(n)` | Забрать людей в Казармы в начале обучения; обучение закончено — их считает Армия. |
|
||||||
|
| `CompleteTraining(type, n)` | Передать n новобранцев в Армию одним действием: люди уходят из Населения, только если Армия их приняла. Иначе ничего не меняется — никто не теряется и не дублируется. Так заканчивают обучение Казармы (`GameProgress`). |
|
||||||
|
| `WasStarvingAt(unixTime)` | Голодал ли город в этот момент (секунды Unix, время `GameClock`). Позже последнего расчёта -- как сейчас; старше 48 часов -- нет. |
|
||||||
| `Tick(unixNow)` | Досчитать население до момента `unixNow`. |
|
| `Tick(unixNow)` | Досчитать население до момента `unixNow`. |
|
||||||
|
|
||||||
Получить: `GameServices.Get<IPopulation>()`.
|
Получить: `GameServices.Get<IPopulation>()`.
|
||||||
@@ -48,7 +58,7 @@
|
|||||||
|
|
||||||
## Сохранение
|
## Сохранение
|
||||||
|
|
||||||
Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `kit=`.
|
Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `training=` (новобранцы в Казармах), `famine=` (история голода: `начало-конец;начало-конец`, конец 0 -- голод идёт), `kit=`.
|
||||||
|
|
||||||
Сброс (`ResetState`): стартовый город, 270 жителей. Стартовое войско (30 пехоты) и стартовые ресурсы выдаёт `App/GameModules` после сброса и при первой игре (`NewGameStartedEvent`) — один раз (`kit=1`). Старым сохранениям без секции населения стартовый набор не выдаётся.
|
Сброс (`ResetState`): стартовый город, 270 жителей. Стартовое войско (30 пехоты) и стартовые ресурсы выдаёт `App/GameModules` после сброса и при первой игре (`NewGameStartedEvent`) — один раз (`kit=1`). Старым сохранениям без секции населения стартовый набор не выдаётся.
|
||||||
|
|
||||||
@@ -57,6 +67,8 @@
|
|||||||
| Файл | Что делает |
|
| Файл | Что делает |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `App/GameModules.cs` | Создаёт модуль с числами из `EconomyConfig`, подписывает на события, выдаёт стартовый набор, держит ресурс «Люди» на верхней панели равным `Total`. |
|
| `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/PopulationTicker.cs` | Раз в 2 секунды вызывает `Tick` (модуль без Unity и не знает про `Update`). |
|
||||||
| `App/DebugPanel.cs` | Кнопка «DEV»: «+1 час», «+10 часов», ресурсы, «Еда = 0». Только редактор и Development Build. |
|
| `App/DebugPanel.cs` | Кнопка «DEV»: «+1 час», «+10 часов», ресурсы, «Еда = 0». Только редактор и Development Build. |
|
||||||
|
|
||||||
@@ -80,18 +92,30 @@
|
|||||||
- перемотка идёт через те же правила;
|
- перемотка идёт через те же правила;
|
||||||
- сохранение;
|
- сохранение;
|
||||||
- стартовый набор — один раз;
|
- стартовый набор — один раз;
|
||||||
- сброс.
|
- сброс;
|
||||||
|
- найм: 150 свободных, резерв 30 (Ратуша 6) → не больше 120; стартовый город — резерв 10; резерв по уровням Ратуши 10/20/30; стартовая расстановка — 120 рабочих; людей столько же, новобранцы не считаются дважды; рабочие остаются на работе; новобранцы едят как воины и сохраняются; старый заказ обучения не уводит в минус;
|
||||||
|
- история голода: бой офлайн видит голод на свой момент, история сохраняется, сбрасывается и забывается через 48 часов.
|
||||||
|
|
||||||
## Решения и план
|
## Решения и план
|
||||||
|
|
||||||
- **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`.
|
- **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`.
|
||||||
- **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»).
|
- **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»).
|
||||||
- **Этап 3:** сделано 27.10.2026 — рабочие по профессиям, Дома, добыча в общем расчёте.
|
- **Этап 3:** сделано 27.10.2026 — рабочие по профессиям, Дома, добыча в общем расчёте.
|
||||||
- **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.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), добыча в общем расчёте. |
|
| 27.10.2026 | Claude | Рабочие по профессиям (`Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`, автозаполнение), Дома в пределе жилья, базовый предел 300 (решение 19.10), добыча в общем расчёте. |
|
||||||
| 26.10.2026 | Claude | Создан модуль: жители, еда, прирост, голод, предел жилья, офлайн-прогресс, секция `population`, тесты. |
|
| 26.10.2026 | Claude | Создан модуль: жители, еда, прирост, голод, предел жилья, офлайн-прогресс, секция `population`, тесты. |
|
||||||
|
|||||||
+70
-15
@@ -1,26 +1,69 @@
|
|||||||
# Модульная система «Богатырей»
|
# Модульная система «Богатырей»
|
||||||
|
|
||||||
Игра собрана из девяти модулей вокруг общего **Игрового ядра**. Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
|
Игра собрана из десяти модулей вокруг общего **Игрового ядра** (регламент 31.10.2026; модуль Разведки — в ветке Саши `feature/intelligence-m1`, на схеме появится с ней). Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
|
||||||
|
|
||||||
```
|
```
|
||||||
Игровое ядро (Bogatyri.Core)
|
┌─────────────────────────────────────────┐
|
||||||
контракты (IEconomy, IArmy...) · события (EventBus) · реестр (GameServices)
|
│ ОБЩЕЕ ИГРОВОЕ ЯДРО (Core) │
|
||||||
┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┬──────────┬──────────┐
|
│ контракты · события · реестр · время игры │
|
||||||
Экономика Поселение Армия Глобальный мир Боевая система Сохранения Чат Круг
|
└─────────────────────────────────────────┘
|
||||||
ресурсы, здания, войска, карта, урон, потери, игровое мир и объединение
|
все модули видят только ядро, друг друга — нет
|
||||||
производство развитие характеристики перемещения исход состояние Круг игроков
|
|
||||||
▲
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
App/GameModules — точка сборки: регистрирует модули и соединяет их события
|
│ ЭКОНОМИКА │ │ ПОСЕЛЕНИЕ │
|
||||||
со старыми системами (GameProgress, Казармы, панели UI)
|
│ ресурсы, склады, │ │ здания, Ратуша, │
|
||||||
|
│ лимиты, добыча │ │ участки, стройка │
|
||||||
|
│ рабочими │ │ │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ НАСЕЛЕНИЕ │ │ АРМИЯ │
|
||||||
|
│ люди, рабочие, еда, │ │ воины по типам: дома,│
|
||||||
|
│ голод, прирост, │ │ в гарнизоне, │
|
||||||
|
│ жильё │ │ в походе │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ ГЛОБАЛЬНЫЙ МИР │ │ БОЕВАЯ СИСТЕМА │
|
||||||
|
│ карта, походы, замок,│ │ сила, контры, стены, │
|
||||||
|
│ соседи, лагеря │ │ удача ±5%, голод, │
|
||||||
|
│ разбойников │ │ прогноз │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ КРУГ │ │ ЧАТ │
|
||||||
|
│ объединение игроков, │ │ общий мир и чат │
|
||||||
|
│ роли │ │ Круга │
|
||||||
|
└──────────────────────┘ └──────────────────────┘
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ СОХРАНЕНИЯ │
|
||||||
|
│ save.json, у каждого │
|
||||||
|
│ модуля своя секция │
|
||||||
|
└──────────────────────┘
|
||||||
|
|
||||||
|
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.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`, `Bogatyri.Economy.Rules` (без Unity) | `Assets/Scripts/Modules/Economy` | [Economy.md](Economy.md) |
|
||||||
| Поселение | `Bogatyri.Settlement` | `Assets/Scripts/Modules/Settlement` | [Settlement.md](Settlement.md) |
|
| Поселение | `Bogatyri.Settlement` | `Assets/Scripts/Modules/Settlement` | [Settlement.md](Settlement.md) |
|
||||||
| Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.md) |
|
|
||||||
| Население | `Bogatyri.Population` (без Unity) | `Assets/Scripts/Modules/Population` | [Population.md](Population.md) |
|
| Население | `Bogatyri.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.World`, `Bogatyri.World.Simulation` (без Unity) | `Assets/Scripts/Modules/World` | [World.md](World.md) |
|
||||||
| Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) |
|
| Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) |
|
||||||
| Сохранения | `Bogatyri.Save` | `Assets/Scripts/Modules/Save` | [Save.md](Save.md) |
|
| Сохранения | `Bogatyri.Save` | `Assets/Scripts/Modules/Save` | [Save.md](Save.md) |
|
||||||
@@ -47,20 +90,32 @@
|
|||||||
|
|
||||||
EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск: **Window → General → Test Runner → EditMode → Run All**.
|
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):
|
Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026):
|
||||||
|
|
||||||
- [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет);
|
- [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет);
|
||||||
- [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120;
|
- [x] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; резерв по уровню Ратуши 10/20/30 (`PopulationTests`);
|
||||||
- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть);
|
- [x] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (`ArmyService_Garrison_OneSoldierInOnePlace`);
|
||||||
- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу;
|
- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу. Бой уже проверен (`Battle_Famine_HalvesPower_AndStacksWithFatigue`, `World_FamineAtBattleTime_WeakensMarch`: −50% у нападающих и у защитников, вместе с усталостью 40%), история голода на момент боя — `PopulationTests`; добыча кроме еды при голоде ×0.5 и снятие штрафа сразу после еды — `ProductionTests` (31.10);
|
||||||
- [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу.
|
- [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу.
|
||||||
|
|
||||||
## Журнал изменений
|
## Журнал изменений
|
||||||
|
|
||||||
| Дата | Кто | Что |
|
| Дата | Кто | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 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. |
|
| 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. |
|
| 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 | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`), обновлён World. |
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `economy` | Экономика |
|
| `economy` | Экономика |
|
||||||
| `settlement` | Поселение |
|
| `settlement` | Поселение |
|
||||||
| `world` | Глобальный мир |
|
| `world` | Глобальный мир (походы и разбитые лагеря) |
|
||||||
| `circle` | Круг (простые строки, не JSON — модуль без Unity) |
|
| `circle` | Круг (простые строки, не JSON — модуль без Unity) |
|
||||||
| `army` | Армия (простые строки: `troops=…`, `march=…`) |
|
| `army` | Армия (простые строки: `troops=…`, `march=…`) |
|
||||||
| `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) |
|
| `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) |
|
||||||
@@ -71,7 +71,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
|||||||
- **Экономика** — стартовые ресурсы из Inspector `ResourceManager` (лимиты склада пересчитает `App/CitySync` по зданиям);
|
- **Экономика** — стартовые ресурсы из Inspector `ResourceManager` (лимиты склада пересчитает `App/CitySync` по зданиям);
|
||||||
- **Добыча** — мест нет (построек нет);
|
- **Добыча** — мест нет (построек нет);
|
||||||
- **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны;
|
- **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны;
|
||||||
- **Мир** — все походы отменены;
|
- **Мир** — все походы отменены, все лагеря разбойников на месте;
|
||||||
- **Круг** — игрок ни в каком Круге, его Круги распущены;
|
- **Круг** — игрок ни в каком Круге, его Круги распущены;
|
||||||
- **Армия** — войск и походов нет;
|
- **Армия** — войск и походов нет;
|
||||||
- **Население** — стартовый город (270 жителей). После сброса `App/GameModules` выдаёт стартовое войско и ресурсы из `EconomyConfig`.
|
- **Население** — стартовый город (270 жителей). После сброса `App/GameModules` выдаёт стартовое войско и ресурсы из `EconomyConfig`.
|
||||||
@@ -106,6 +106,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
|||||||
|
|
||||||
| Дата | Кто | Что |
|
| Дата | Кто | Что |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. |
|
||||||
| 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). |
|
| 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). |
|
||||||
| 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. |
|
| 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. |
|
||||||
| 23.10.2026 | Claude | Новая секция `army` (модуль Армии). `GameLoadedEvent` публикуется и без файла сохранения. |
|
| 23.10.2026 | Claude | Новая секция `army` (модуль Армии). `GameLoadedEvent` публикуется и без файла сохранения. |
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
# Поселение
|
# Поселение
|
||||||
|
|
||||||
Здания и развитие города: Ратуша, участки под застройку, постройка зданий, восстановление города из сохранения.
|
Здания и развитие города: Ратуша, участки, свободная стройка на сетке, постройка зданий, восстановление города из сохранения. Люди города — отдельный модуль [Население](Population.md), связь только через контракты ядра.
|
||||||
|
|
||||||
|
- **Ответственность:** совместная.
|
||||||
- **Сборка:** `Bogatyri.Settlement`.
|
- **Сборка:** `Bogatyri.Settlement`.
|
||||||
- **Папка:** `Assets/Scripts/Modules/Settlement`.
|
- **Папка:** `Assets/Scripts/Modules/Settlement`.
|
||||||
- **Зависит от:** ядра. Ресурсы на стройку берёт через `IEconomy`.
|
- **Зависит от:** ядра. Ресурсы на стройку берёт через `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`
|
## Контракт `ISettlement`
|
||||||
|
|
||||||
| Свойство | Что значит |
|
| Свойство | Что значит |
|
||||||
@@ -39,10 +59,12 @@
|
|||||||
| Файл | Что это |
|
| Файл | Что это |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `SettlementModule.cs` | Точка входа: `ISettlement`, сохранение города, восстановление на сцене. |
|
| `SettlementModule.cs` | Точка входа: `ISettlement`, сохранение города, восстановление на сцене. |
|
||||||
| `BuildManager.cs` | Режим стройки, список зданий, участки. |
|
| `BuildManager.cs` | Список зданий, постройка на сетке (`BuildAt`), поиск данных здания по имени. |
|
||||||
| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, цена, нужный уровень Ратуши. |
|
| `BuildGrid.cs` | Сетка стройки: область, занятые клетки, рисунок сетки. Там же `BuildFootprint` — размер здания в клетках. |
|
||||||
|
| `BuildGhost.cs` | Призрак здания в режиме стройки. |
|
||||||
|
| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, размер на сетке, цена, нужный уровень Ратуши. |
|
||||||
| `TownHall.cs` | Ратуша и её улучшение. Раньше файл назывался `NewEmptyCSharpScript.cs`. |
|
| `TownHall.cs` | Ратуша и её улучшение. Раньше файл назывался `NewEmptyCSharpScript.cs`. |
|
||||||
| `BuildSlot.cs` | Участок под здание и связь здания с участком. |
|
| `BuildSlot.cs` | Участок под здание (старая схема, участки прячутся) и связь здания с участком. |
|
||||||
| `PlacedBuildingId.cs` | Метка построенного здания. Раньше файл назывался `PlaceBuilding.cs`. |
|
| `PlacedBuildingId.cs` | Метка построенного здания. Раньше файл назывался `PlaceBuilding.cs`. |
|
||||||
|
|
||||||
В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`).
|
В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`).
|
||||||
@@ -53,11 +75,17 @@
|
|||||||
- Лимит населения = база Ратуши + сумма Домов + бонусы, значения из `EconomyConfig`.
|
- Лимит населения = база Ратуши + сумма Домов + бонусы, значения из `EconomyConfig`.
|
||||||
- Уровень поселения задаёт доступные уровни и количество жилья, а также минимальный гражданский резерв.
|
- Уровень поселения задаёт доступные уровни и количество жилья, а также минимальный гражданский резерв.
|
||||||
- **Этап 3:** рабочие места в зданиях (`BuildingData`), экран распределения рабочих.
|
- **Этап 3:** рабочие места в зданиях (`BuildingData`), экран распределения рабочих.
|
||||||
|
- **Стройка на сетке (27.10.2026, по просьбе Володи):** вернулись к свободной стройке — игрок сам выбирает место, сетка показывает границы и красные занятые клетки, у зданий разный размер.
|
||||||
- **Этап 4 (утверждён без изменений):** доступность зданий зависит от месторождений позиции.
|
- **Этап 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 | `ResetState()` для сброса сохранений. Убранные здания помечаются, чтобы запись в том же кадре их не сохранила. |
|
||||||
| 19.10.2026 | Claude | Модуль выделен. Стройка и Ратуша платят через `IEconomy`. Сохранение города перенесено из `SaveManager` в `SettlementModule` (секция `"settlement"`). Восстановление защищено от двойного запуска и от освобождения занятого участка. Файлы переименованы под имена классов. |
|
| 19.10.2026 | Claude | Модуль выделен. Стройка и Ратуша платят через `IEconomy`. Сохранение города перенесено из `SaveManager` в `SettlementModule` (секция `"settlement"`). Восстановление защищено от двойного запуска и от освобождения занятого участка. Файлы переименованы под имена классов. |
|
||||||
|
|||||||
+41
-5
@@ -1,6 +1,6 @@
|
|||||||
# Глобальный мир
|
# Глобальный мир
|
||||||
|
|
||||||
Карта и перемещения: замок с боссом, 20 соседних городов, походы по реальному времени (идут, даже когда игра закрыта).
|
Карта и перемещения: замок с боссом, 20 соседних городов, 12 лагерей разбойников, походы по реальному времени (идут, даже когда игра закрыта).
|
||||||
|
|
||||||
- **Сборки:**
|
- **Сборки:**
|
||||||
- `Bogatyri.World.Simulation` — карта и походы, чистый C# без Unity (переедет на сервер);
|
- `Bogatyri.World.Simulation` — карта и походы, чистый C# без Unity (переедет на сервер);
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
- `MarchStartedEvent` — войско выступило;
|
- `MarchStartedEvent` — войско выступило;
|
||||||
- `MarchBattleResolvedEvent` — бой у цели прошёл;
|
- `MarchBattleResolvedEvent` — бой у цели прошёл;
|
||||||
- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetName`, `LootText`.
|
- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetEmpty` (лагерь уже был разбит, боя не было), `TargetName`, `LootText`.
|
||||||
|
|
||||||
**Слушает:** ничего.
|
**Слушает:** ничего.
|
||||||
|
|
||||||
@@ -36,9 +36,9 @@
|
|||||||
|
|
||||||
## Сохранение
|
## Сохранение
|
||||||
|
|
||||||
Секция `"world"`: `{ "marches": [ MarchData ] }`.
|
Секция `"world"`: `{ "marches": [ MarchData ], "camps": [ { "id", "respawnMs" } ] }` — походы и разбитые лагеря с моментом их появления. Старые сохранения без `camps` читаются: все лагеря на месте.
|
||||||
|
|
||||||
Сброс (`ResetState`): все походы отменяются. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией.
|
Сброс (`ResetState`): все походы отменяются, все лагеря на месте. Войска в походе не возвращаются: их сбрасывает `GameProgress` вместе с армией.
|
||||||
|
|
||||||
Раньше походы лежали в `progress.json` (`sashaMarches`). При первом запуске `App/GameModules` переносит их в модуль Мира и удаляет из `progress.json` только после записи `save.json`.
|
Раньше походы лежали в `progress.json` (`sashaMarches`). При первом запуске `App/GameModules` переносит их в модуль Мира и удаляет из `progress.json` только после записи `save.json`.
|
||||||
|
|
||||||
@@ -47,9 +47,38 @@
|
|||||||
| Файл | Что это |
|
| Файл | Что это |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. |
|
| `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. |
|
||||||
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей из seed. |
|
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей и 12 лагерей из seed. |
|
||||||
| `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. |
|
| `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/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`.
|
||||||
|
|
||||||
**В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену).
|
**В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену).
|
||||||
@@ -59,11 +88,18 @@
|
|||||||
- **Этап 0, решено 19.10.2026:** осталась одна карта мира — эта. Маленькая карта (панель «МИР», `Meta/World`) удалена. Перед удалением проверены ссылки в коде, сценах и префабах. Перенос сохранений: солдаты из её походов (`marches` в `progress.json`) возвращаются в город при загрузке — отправленные, а если отряд уже шёл назад, то выжившие. Добыча таких походов не переносится.
|
- **Этап 0, решено 19.10.2026:** осталась одна карта мира — эта. Маленькая карта (панель «МИР», `Meta/World`) удалена. Перед удалением проверены ссылки в коде, сценах и префабах. Перенос сохранений: солдаты из её походов (`marches` в `progress.json`) возвращаются в город при загрузке — отправленные, а если отряд уже шёл назад, то выжившие. Добыча таких походов не переносится.
|
||||||
- **Этап 4 (утверждён без изменений):** у позиций на карте — месторождения и их качество.
|
- **Этап 4 (утверждён без изменений):** у позиций на карте — месторождения и их качество.
|
||||||
- **Этап 6:** временные ресурсные точки и кристальные разломы; войска на точке недоступны дома.
|
- **Этап 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`. |
|
| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе. В `MarchReturnedEvent` добавлены `Sent`, `TargetName`, `LootText`. |
|
||||||
| 19.10.2026 | Claude | Маленькая карта мира (`Meta/World`: WorldSystem, WorldMapPanel, WorldData, WorldLine) удалена, окно `Panel_World` убрано из сцены города. Кнопка «МИР» открывает эту карту. Солдаты из походов старой карты возвращаются в город. |
|
| 19.10.2026 | Claude | Маленькая карта мира (`Meta/World`: WorldSystem, WorldMapPanel, WorldData, WorldLine) удалена, окно `Panel_World` убрано из сцены города. Кнопка «МИР» открывает эту карту. Солдаты из походов старой карты возвращаются в город. |
|
||||||
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. |
|
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. |
|
||||||
|
|||||||
Reference in New Issue
Block a user