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 UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Окно здания и рабочих (этап 3). Открывается тапом по зданию или кнопкой "Рабочие" в панели DEV.
|
||||
// - сверху: что это за здание, его уровень и кнопка "Улучшить" (больше мест и эффективность / больше лимит / больше жилья);
|
||||
// - ниже: все профессии с "−" и "+", сколько рабочих стоит, сколько мест и сколько добывается в час;
|
||||
// - внизу: "Свободно: N из M".
|
||||
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль добычи.
|
||||
// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь).
|
||||
// У каждого здания своё окно:
|
||||
// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+",
|
||||
// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена;
|
||||
// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена;
|
||||
// - Дом: сколько жилья даёт, население / предел, улучшение и цена;
|
||||
// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения;
|
||||
// - Кристалл: где добывается, запас / лимит;
|
||||
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
|
||||
// кнопка "Нанять войска" (панель "Войска").
|
||||
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
|
||||
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
|
||||
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
|
||||
public static class BuildingWindow
|
||||
{
|
||||
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 OnStorageTapped(StorageBuilding building) { Open(building != null ? building.gameObject : null); }
|
||||
public static void OnTownHallTapped(TownHall townHall) { Open(townHall != null ? townHall.gameObject : null); }
|
||||
|
||||
public static void Open(GameObject building)
|
||||
{
|
||||
// В режиме стройки тап -- это выбор участка, а не здания
|
||||
// В режиме стройки тап -- это выбор места, а не здания
|
||||
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
||||
focus = building;
|
||||
Rebuild();
|
||||
@@ -24,135 +37,440 @@ public static class BuildingWindow
|
||||
|
||||
public static void Close()
|
||||
{
|
||||
if (root != null) Object.Destroy(root);
|
||||
if (root != null) UnityEngine.Object.Destroy(root);
|
||||
root = null;
|
||||
focus = null;
|
||||
live.Clear();
|
||||
}
|
||||
|
||||
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);
|
||||
root.AddComponent<BuildingWindowTicker>();
|
||||
|
||||
if (focus != null) BuildingSection(card, focus);
|
||||
WorkersSection(card);
|
||||
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
|
||||
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
|
||||
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
|
||||
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
|
||||
|
||||
if (focus == null) WorkersList(card);
|
||||
else if (townHall != null) TownHallSection(card, townHall);
|
||||
else if (barracks != null) BarracksSection(card, barracks);
|
||||
else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc);
|
||||
else if (rc != null && rc.IsHouse) HouseSection(card, rc);
|
||||
else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card);
|
||||
else if (storage != null) StorageSection(card, storage);
|
||||
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
|
||||
}
|
||||
|
||||
internal static void Tick()
|
||||
{
|
||||
if (root == null) return;
|
||||
if (focus == null && FocusWasDestroyed) { Close(); return; }
|
||||
foreach (var update in live) update();
|
||||
}
|
||||
|
||||
static bool focusSet;
|
||||
static bool FocusWasDestroyed => focusSet && focus == null;
|
||||
|
||||
static string TitleFor(GameObject building)
|
||||
{
|
||||
focusSet = building != null;
|
||||
if (building == null) return "РАБОЧИЕ";
|
||||
var townHall = building.GetComponent<TownHall>();
|
||||
if (townHall != null) return $"РАТУША — {townHall.currentLevel} УР.";
|
||||
var id = building.GetComponent<PlacedBuildingId>();
|
||||
var leveled = building.GetComponent<ILeveledBuilding>();
|
||||
string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name;
|
||||
return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper();
|
||||
}
|
||||
|
||||
// ---------------- Здание: что даёт и улучшение ----------------
|
||||
// ---------------- Добывающее здание ----------------
|
||||
|
||||
static void 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 rc = building.GetComponent<ResourceCollector>();
|
||||
var storage = building.GetComponent<StorageBuilding>();
|
||||
var leveled = building.GetComponent<ILeveledBuilding>();
|
||||
string info;
|
||||
int level = townHall.currentLevel;
|
||||
int next = level + 1;
|
||||
var p = config.population;
|
||||
|
||||
if (rc != null && WorkJobs.TryFromResource(rc.resourceType, out WorkJob job) && GameModules.Production != null)
|
||||
{
|
||||
var p = GameModules.Production;
|
||||
info = $"Здание даёт {p.BuildingSlots(rc.level)} мест, норма ×{p.BuildingEfficiency(rc.level):0.##}.\n" +
|
||||
$"Всего мест «{WorkJobs.Name(job)}» в городе: {p.Slots(job)}.";
|
||||
}
|
||||
else if (rc != null && rc.IsHouse)
|
||||
{
|
||||
int perLevel = config.population.housingPerHouseLevel;
|
||||
string cap = GameServices.TryGet(out IPopulation pop) ? $"\nНаселение: {pop.Total} / {pop.HousingCap}" : "";
|
||||
info = $"Дом: +{perLevel * rc.level} к пределу жилья (+{perLevel} за уровень).{cap}";
|
||||
}
|
||||
else if (rc != null && rc.resourceType == ResourceType.Gems)
|
||||
{
|
||||
info = "Синий кристалл добывается в кристальных разломах на карте мира.";
|
||||
}
|
||||
else if (storage != null)
|
||||
{
|
||||
var s = config.storage;
|
||||
info = storage.kind == StorageBuilding.Kind.Warehouse
|
||||
? $"Склад: +{s.perWarehouseLevel * storage.level:0} к лимиту дерева, камня, металла и золота."
|
||||
: $"Амбар: +{s.perGranaryLevel * storage.level:0} к лимиту еды.";
|
||||
}
|
||||
else info = "";
|
||||
UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
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 (leveled.Level >= config.upgrade.maxLevel)
|
||||
if (level >= townHall.maxLevel)
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
var cost = config.UpgradeCost(leveled.Level);
|
||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
||||
var b = UIKit.Button(card, $"Улучшить до {leveled.Level + 1} ур. ({CostText(cost)})", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
// Что даст следующий уровень
|
||||
Header(card, $"Уровень {next}");
|
||||
var gains = new List<string> { $"жильё +{p.housingPerTownHallLevel}" };
|
||||
int nowReserve = ReserveAt(level), nextReserve = ReserveAt(next);
|
||||
if (nextReserve != nowReserve) gains.Add($"резерв для найма {nowReserve} → {nextReserve}");
|
||||
UIKit.Text(card, string.Join(", ", gains.ToArray()), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
string unlocks = UnlocksAt(next);
|
||||
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
|
||||
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
bool canPay = townHall.CanUpgrade();
|
||||
var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
leveled.Level = leveled.Level + 1;
|
||||
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
townHall.Upgrade();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {leveled.Level} уровня");
|
||||
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
}
|
||||
|
||||
// ---------------- Казармы ----------------
|
||||
|
||||
static void BarracksSection(RectTransform card, Barracks barracks)
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
UIKit.Text(card, $"Казармы обучают войска. Казарм в городе: {Barracks.Count}, мест в очереди: {Barracks.QueueSize}. " +
|
||||
$"Новые войска открывает уровень самых прокачанных Казарм ({Barracks.MaxLevel} ур.) вместе с уровнем Ратуши.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (gp != null)
|
||||
{
|
||||
Header(card, "Обучение");
|
||||
Live(card, () =>
|
||||
{
|
||||
var d = gp.Data;
|
||||
if (d.training.Count == 0) return "Никто не обучается";
|
||||
var o = d.training[0];
|
||||
string more = d.training.Count > 1 ? $" (ещё заказов: {d.training.Count - 1})" : "";
|
||||
return $"{GameData.Troops[o.troopIndex].name} ×{o.count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim);
|
||||
|
||||
Header(card, "Войска");
|
||||
for (int i = 0; i < GameData.Troops.Count; i++)
|
||||
{
|
||||
string lockText = gp.TroopLockText(i);
|
||||
UIKit.Text(card, lockText.Length == 0
|
||||
? $"<color=#9be59b>{GameData.Troops[i].name} — открыты</color>"
|
||||
: $"{GameData.Troops[i].name} — нужно: {lockText}",
|
||||
24, lockText.Length == 0 ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
}
|
||||
}
|
||||
|
||||
// Что откроет следующий уровень этих Казарм
|
||||
var opens = new List<string>();
|
||||
if (gp != null)
|
||||
for (int i = 0; i < GameData.Troops.Count; i++)
|
||||
if (gp.RequiredBarracksLevel(i) == barracks.level + 1 && Barracks.MaxLevel <= barracks.level) opens.Add(GameData.Troops[i].name);
|
||||
string effect = opens.Count > 0
|
||||
? $"откроются: {string.Join(", ", opens.ToArray())} (если хватает уровня Ратуши)"
|
||||
: "ближе к новым войскам";
|
||||
UpgradeBlock(card, barracks.level, effect, () => barracks.Level = barracks.level + 1);
|
||||
|
||||
UIKit.Button(card, "Нанять войска", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
Close();
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
|
||||
static int ReserveAt(int townHallLevel)
|
||||
{
|
||||
var table = GameModules.Population != null ? GameModules.Population.Rules.CivilianReserveByTownHallLevel : null;
|
||||
if (table == null || table.Length == 0) return 0;
|
||||
return table[Mathf.Clamp(townHallLevel, 1, table.Length) - 1];
|
||||
}
|
||||
|
||||
// Здания и войска, которые открываются ровно на этом уровне Ратуши
|
||||
static string UnlocksAt(int townHallLevel)
|
||||
{
|
||||
var names = new List<string>();
|
||||
if (BuildManager.Instance != null && BuildManager.Instance.allBuildings != null)
|
||||
foreach (var b in BuildManager.Instance.allBuildings)
|
||||
if (b != null && b.requiredTownHallLevel == townHallLevel) names.Add(b.buildingName);
|
||||
foreach (var t in GameData.Troops)
|
||||
if (t.requiredTownHall == townHallLevel) names.Add(t.name);
|
||||
return string.Join(", ", names.ToArray());
|
||||
}
|
||||
|
||||
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
|
||||
|
||||
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
if (level >= config.upgrade.maxLevel)
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
Header(card, $"Улучшение до {level + 1} ур.");
|
||||
UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
var cost = config.UpgradeCost(level);
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
|
||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
||||
var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
raiseLevel();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {level + 1} уровня");
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
}
|
||||
|
||||
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
|
||||
static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
GameServices.TryGet(out IEconomy economy);
|
||||
var parts = new string[cost.Length];
|
||||
for (int i = 0; i < cost.Length; i++) 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);
|
||||
}
|
||||
|
||||
// ---------------- Рабочие по профессиям ----------------
|
||||
// ---------------- Склад: полоска и время до заполнения ----------------
|
||||
|
||||
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))
|
||||
{
|
||||
UIKit.Text(card, "Модули Населения и добычи не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
{
|
||||
var job = (WorkJob)j;
|
||||
int slots = prod.Slots(job);
|
||||
int workers = pop.GetWorkers(job);
|
||||
var row = UIKit.Row(card, 72, 8);
|
||||
|
||||
string rate = slots > 0 ? $" <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>" : "";
|
||||
var label = UIKit.Text(row, slots > 0 ? $"{WorkJobs.Name(job)}: {workers} / {slots}{rate}" : $"{WorkJobs.Name(job)}: нет зданий",
|
||||
24, slots > 0 ? UIKit.Theme.Text : UIKit.Theme.TextDim);
|
||||
var label = Live(row, () =>
|
||||
{
|
||||
int slots = prod.Slots(job);
|
||||
if (slots <= 0) return $"{WorkJobs.Name(job)}: нет зданий";
|
||||
return $"{WorkJobs.Name(job)}: {pop.GetWorkers(job)} / {slots} <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24;
|
||||
label.alignment = TextAlignmentOptions.Left;
|
||||
UIKit.Size(label, -1, 72, 1);
|
||||
|
||||
StepButton(row, "−5", job, -5, slots > 0 && workers > 0);
|
||||
StepButton(row, "−1", job, -1, slots > 0 && workers > 0);
|
||||
StepButton(row, "+1", job, +1, slots > 0 && workers < slots && pop.FreeWorkers > 0);
|
||||
StepButton(row, "+5", job, +5, slots > 0 && workers < slots && pop.FreeWorkers > 0);
|
||||
StepButton(row, "−5", job, -5);
|
||||
StepButton(row, "−1", job, -1);
|
||||
StepButton(row, "+1", job, +1);
|
||||
StepButton(row, "+5", job, +5);
|
||||
}
|
||||
|
||||
UIKit.Text(card, $"Свободно: <b>{pop.FreeWorkers}</b> из {pop.Workforce} рабочих • остальные жители: {Mathf.Max(0, pop.Civilians - pop.Workforce)}",
|
||||
24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
Live(card, () => $"Свободных жителей: <b>{pop.FreeWorkers}</b> из {pop.Civilians}", 24, UIKit.Theme.Gold);
|
||||
}
|
||||
|
||||
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);
|
||||
Rebuild();
|
||||
Tick();
|
||||
}, 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;
|
||||
if (production == null || population == null) return;
|
||||
|
||||
// Места для рабочих: где мест стало больше (построили, улучшили) -- ставим туда свободных
|
||||
var before = new int[WorkJobs.Count];
|
||||
for (int j = 0; j < WorkJobs.Count; j++) before[j] = production.Slots((WorkJob)j);
|
||||
// Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам
|
||||
// не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем.
|
||||
production.SetBuildings(city.Workplaces);
|
||||
population.SetHouseLevels(city.HouseLevels);
|
||||
population.ClampJobs();
|
||||
|
||||
if (!population.JobsInitialized)
|
||||
{
|
||||
population.AutoFillAll(); // первый запуск после этапа 3 или новая игра
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int j = 0; j < WorkJobs.Count; j++)
|
||||
if (production.Slots((WorkJob)j) > before[j]) population.AutoFill((WorkJob)j);
|
||||
}
|
||||
// Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения
|
||||
if (!population.JobsInitialized) population.AutoFillAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ using UnityEngine.UI;
|
||||
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
|
||||
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
|
||||
//
|
||||
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение
|
||||
// войск пока идут по реальным часам.
|
||||
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение
|
||||
// войск пока идёт по реальным часам.
|
||||
public class DebugPanel : MonoBehaviour
|
||||
{
|
||||
static DebugPanel instance;
|
||||
@@ -38,15 +38,23 @@ public class DebugPanel : MonoBehaviour
|
||||
|
||||
void Build()
|
||||
{
|
||||
// Панель -- внутри безопасной зоны, чтобы не уходила под вырез камеры
|
||||
var safe = new GameObject("SafeArea", typeof(RectTransform)).GetComponent<RectTransform>();
|
||||
safe.SetParent(transform, false);
|
||||
safe.anchorMin = Vector2.zero;
|
||||
safe.anchorMax = Vector2.one;
|
||||
safe.offsetMin = safe.offsetMax = Vector2.zero;
|
||||
safe.gameObject.AddComponent<SafeArea>();
|
||||
|
||||
// Кнопка-переключатель слева посередине: там нет кнопок HUD
|
||||
var toggle = UIKit.Button(transform, "DEV", new Color(0.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;
|
||||
trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f);
|
||||
trt.pivot = new Vector2(0, 0.5f);
|
||||
trt.anchoredPosition = new Vector2(12, 0);
|
||||
trt.sizeDelta = new Vector2(110, 64);
|
||||
|
||||
var card = UIKit.Card(transform, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
|
||||
var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
|
||||
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
|
||||
card.pivot = new Vector2(0, 0.5f);
|
||||
card.anchoredPosition = new Vector2(135, 0);
|
||||
@@ -73,6 +81,7 @@ public class DebugPanel : MonoBehaviour
|
||||
|
||||
var row4 = UIKit.Row(card, 70, 10);
|
||||
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
|
||||
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
|
||||
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ public class EconomyConfig : ScriptableObject
|
||||
|
||||
[Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")]
|
||||
public int maxOfflineHours = 48;
|
||||
|
||||
[Tooltip("Гражданский резерв по уровню Ратуши: [0] -- уровень 1, [1] -- уровень 2... Столько свободных жителей нельзя забрать в войско. Регламент 31.10, ТЕСТ: 1-2 -- 10, 3-5 -- 20, 6-10 -- 30")]
|
||||
public int[] civilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -60,6 +63,8 @@ public class EconomyConfig : ScriptableObject
|
||||
public int workerSlotsPerLevel = 10;
|
||||
[Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")]
|
||||
public float efficiencyPerLevel = 0.1f;
|
||||
[Tooltip("Голод: множитель добычи всех ресурсов, кроме еды (0.5 -- вдвое меньше). Еда добывается без штрафа. Регламент 31.10, ТЕСТ")]
|
||||
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -104,14 +109,18 @@ public class EconomyConfig : ScriptableObject
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class SoldierSettings // этап 5: цена солдата (пока не используется)
|
||||
public class SoldierSettings // этап 5: цена солдата
|
||||
{
|
||||
[Tooltip("Сколько жителей стоит один воин (берутся из жителей при начале обучения)")]
|
||||
public int people = 1;
|
||||
public float food = 15f;
|
||||
public float iron = 5f;
|
||||
public float gold = 5f;
|
||||
[Tooltip("Время подготовки, секунд. Не утверждено")]
|
||||
public int trainSeconds = 60;
|
||||
[Tooltip("Уровень Казарм, с которого открывается тип войск (порядок как в GameData.Troops: пехота, лучники, кавалерия, осадные). " +
|
||||
"Нужен и уровень Ратуши. Считаются самые прокачанные Казармы. ТЕСТ, не утверждено")]
|
||||
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
|
||||
@@ -16,8 +16,11 @@ public static class GameModules
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||||
static void Register()
|
||||
{
|
||||
// Боевая система -- чистый C#, сама зарегистрироваться не может
|
||||
GameServices.Register<IBattleSystem>(new BattleSystem());
|
||||
// Боевая система -- чистый C#, сама зарегистрироваться не может.
|
||||
// Числа боя -- из таблицы BattleConfig, сила воина каждого типа -- из GameData.Troops
|
||||
var unitPower = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
|
||||
GameServices.Register<IBattleSystem>(new BattleSystem(BattleConfig.Load().ToRules(unitPower)));
|
||||
|
||||
// Армия -- чистый C#, единственное место хранения войск (секция "army")
|
||||
var army = new ArmyService();
|
||||
@@ -47,6 +50,8 @@ public static class GameModules
|
||||
ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped;
|
||||
StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped;
|
||||
StorageBuilding.Tapped += BuildingWindow.OnStorageTapped;
|
||||
TownHall.Tapped -= BuildingWindow.OnTownHallTapped;
|
||||
TownHall.Tapped += BuildingWindow.OnTownHallTapped;
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс
|
||||
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
|
||||
@@ -146,7 +151,11 @@ public static class GameModules
|
||||
HousingBase = p.housingBase,
|
||||
HousingPerTownHallLevel = p.housingPerTownHallLevel,
|
||||
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 },
|
||||
SlotsPerBuilding = p.workerSlotsT1,
|
||||
SlotsPerLevel = p.workerSlotsPerLevel,
|
||||
EfficiencyPerLevel = p.efficiencyPerLevel
|
||||
EfficiencyPerLevel = p.efficiencyPerLevel,
|
||||
FamineNonFoodMultiplier = p.famineNonFoodMultiplier
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ public class CameraController : MonoBehaviour
|
||||
void Update()
|
||||
{
|
||||
// --- Перемещение по зажатой левой кнопке мыши ---
|
||||
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI()) // не тащим камеру, когда листаем панель
|
||||
if (UIBlocker.WorldInputCaptured) _isDragging = false; // палец тащит призрак здания -- камера стоит
|
||||
|
||||
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI() && !UIBlocker.WorldInputCaptured) // не тащим камеру, когда листаем панель
|
||||
{
|
||||
_lastMousePosition = Input.mousePosition;
|
||||
_isDragging = true;
|
||||
|
||||
@@ -2,9 +2,8 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// Полоска над зданием (этап 3): рабочие этой профессии / места в городе.
|
||||
// Раньше показывала накопленный запас кликера -- кликер убран, добычу дают рабочие.
|
||||
// У Дома и Кристалла рабочих нет -- там подпись уровня, полоска скрыта.
|
||||
// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию).
|
||||
// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается.
|
||||
public class BuildingUI : MonoBehaviour
|
||||
{
|
||||
[Header("Настройки")]
|
||||
@@ -14,8 +13,16 @@ public class BuildingUI : MonoBehaviour
|
||||
|
||||
float next;
|
||||
|
||||
public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (!ShowLabels)
|
||||
{
|
||||
HideLabel();
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
if (building == null)
|
||||
{
|
||||
Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!");
|
||||
@@ -25,6 +32,18 @@ public class BuildingUI : MonoBehaviour
|
||||
UpdateUI();
|
||||
}
|
||||
|
||||
// Прячем холст с подписью и полоской. Сам объект здания не трогаем: компонент висит на его корне
|
||||
void HideLabel()
|
||||
{
|
||||
foreach (Component c in new Component[] { stokText, progressBar })
|
||||
{
|
||||
if (c == null) continue;
|
||||
var canvas = c.GetComponentInParent<Canvas>(true);
|
||||
GameObject target = canvas != null && canvas.gameObject != gameObject ? canvas.gameObject : c.gameObject;
|
||||
if (target != gameObject) target.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < next) return;
|
||||
|
||||
@@ -5,13 +5,25 @@ using TMPro;
|
||||
// КАЗАРМЫ -- здание для найма войск.
|
||||
// - Без казарм нанимать войска нельзя.
|
||||
// - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5).
|
||||
// - Нажатие на казармы открывает панель "Войска" (найм).
|
||||
// - Над зданием подпись: что сейчас обучается и сколько осталось.
|
||||
// - Уровень Казарм открывает новые типы войск (вместе с уровнем Ратуши; числа -- EconomyConfig, раздел "Солдат", ТЕСТ).
|
||||
// Считаются самые прокачанные Казармы. Уровень сохраняет модуль Поселения (ILeveledBuilding).
|
||||
// - Тап по казармам открывает окно Казарм (App/BuildingWindow): очередь, войска, улучшение, кнопка "Нанять войска".
|
||||
// - Подписи над зданием больше нет -- всё в окне (вернуть: ShowLabel = true).
|
||||
// Вешается на префаб 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 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); } }
|
||||
@@ -24,6 +36,7 @@ public class Barracks : MonoBehaviour
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (!ShowLabel) return;
|
||||
// подпись над зданием (обычный 3D-текст, не UI)
|
||||
var go = new GameObject("BarracksLabel");
|
||||
go.transform.SetParent(transform, false);
|
||||
@@ -44,6 +57,7 @@ public class Barracks : MonoBehaviour
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (label == null) return;
|
||||
if (Time.unscaledTime < next) return;
|
||||
next = Time.unscaledTime + 0.5f;
|
||||
Refresh();
|
||||
@@ -64,10 +78,18 @@ public class Barracks : MonoBehaviour
|
||||
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап (а не перетаскивание камеры) -- окно Казарм
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
BuildingWindow.Open(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// НОВАЯ СХЕМА СТРОЙКИ (как в Rise of Kingdoms):
|
||||
// 1. Кнопка "СТРОЙКА" включает режим строительства -- появляются свободные участки и подсказка сверху.
|
||||
// 2. Тап по свободному участку -> окно со зданиями (цена, что доступно по уровню Ратуши).
|
||||
// 3. "Построить" -> здание встаёт на участок. Режим остаётся включённым, можно строить дальше.
|
||||
// 4. Кнопка "ВЫЙТИ" (та же кнопка или в подсказке) -- выключает режим, участки снова прячутся.
|
||||
// Вне режима пустые участки не видны и не мешают, при запуске игры ничего не открывается.
|
||||
// СВОБОДНАЯ СТРОЙКА НА СЕТКЕ (игрок сам выбирает место):
|
||||
// 1. Кнопка "СТРОЙКА" включает режим: на земле появляется сетка (красные клетки заняты), сверху подсказка,
|
||||
// и сразу открывается список зданий.
|
||||
// 2. Выбрали здание -> появляется призрак: полупрозрачное здание и пятно его клеток.
|
||||
// Зелёное пятно -- можно ставить, красное -- занято или за границей сетки.
|
||||
// 3. Призрак двигают пальцем (тащить за само пятно) или тапом по земле -- он прыгает туда.
|
||||
// Камера в это время не ездит. Размер у зданий разный (BuildingData.footprint).
|
||||
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
|
||||
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
|
||||
//
|
||||
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
|
||||
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
|
||||
public class BuildModeController : MonoBehaviour
|
||||
{
|
||||
public static BuildModeController Instance { get; private set; }
|
||||
@@ -24,14 +28,27 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
GameObject banner;
|
||||
GameObject chooser;
|
||||
GameObject placePanel;
|
||||
TextMeshProUGUI placeInfo;
|
||||
Button placeButton;
|
||||
|
||||
BuildGhost ghost;
|
||||
bool dragging;
|
||||
Vector2 grabOffset;
|
||||
Vector2 downPos;
|
||||
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 OnDestroy() { if (Instance == this) Instance = null; }
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
UIBlocker.WorldInputCaptured = false;
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
@@ -45,85 +62,172 @@ public class BuildModeController : MonoBehaviour
|
||||
{
|
||||
IsActive = active;
|
||||
CloseChooser();
|
||||
ApplySlotVisuals();
|
||||
CancelGhost();
|
||||
UpdateButtonLabel();
|
||||
BuildGrid.Instance.ShowOverlay(active);
|
||||
|
||||
if (banner != null) { Destroy(banner); banner = null; }
|
||||
if (active)
|
||||
{
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
||||
banner = CreateBanner();
|
||||
if (FreeSlots().Count == 0) UIKit.Toast("Свободных участков нет. Улучшайте Ратушу и сносите старое.");
|
||||
OpenChooser();
|
||||
}
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime >= nextVisualRefresh)
|
||||
{
|
||||
nextVisualRefresh = Time.unscaledTime + 0.3f;
|
||||
ApplySlotVisuals(); // участки могли освободиться/заняться (снос, загрузка сохранения)
|
||||
}
|
||||
if (!IsActive) return;
|
||||
// открыли любую панель HUD -- выходим из режима стройки
|
||||
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; }
|
||||
if (chooser != null) 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))
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
downOverUI = UIBlocker.IsPointerOverUI();
|
||||
downOnGhost = !downOverUI && ghost.Contains(world);
|
||||
if (downOnGhost)
|
||||
{
|
||||
dragging = true;
|
||||
grabOffset = (Vector2)ghost.transform.position - world;
|
||||
UIBlocker.WorldInputCaptured = true; // камера не поедет
|
||||
}
|
||||
}
|
||||
|
||||
if (dragging && Input.GetMouseButton(0))
|
||||
{
|
||||
var before = ghost.Cell;
|
||||
ghost.MoveNear(world + grabOffset);
|
||||
if (ghost.Cell != before) RefreshPlacePanel();
|
||||
}
|
||||
|
||||
if (Input.GetMouseButtonUp(0))
|
||||
{
|
||||
Vector2 up = Input.mousePosition;
|
||||
if (downOverUI || UIBlocker.IsPointerOverUI() || (up - downPos).magnitude > 25f) return;
|
||||
HandleTap(up);
|
||||
bool wasDragging = dragging;
|
||||
StopDrag();
|
||||
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;
|
||||
var slot = BuildManager.Instance.SlotAtScreen(screenPos);
|
||||
if (slot == null) return;
|
||||
if (slot.isOccupied)
|
||||
dragging = false;
|
||||
UIBlocker.WorldInputCaptured = false;
|
||||
}
|
||||
|
||||
static Vector2 PointerWorld()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam == null) return Vector2.zero;
|
||||
Vector3 w = cam.ScreenToWorldPoint(Input.mousePosition);
|
||||
return new Vector2(w.x, w.y);
|
||||
}
|
||||
|
||||
// ---------------- призрак ----------------
|
||||
|
||||
void StartGhost(BuildingData data)
|
||||
{
|
||||
CancelGhost();
|
||||
CloseChooser();
|
||||
ghost = BuildGhost.Create(data);
|
||||
|
||||
// ставим в центр экрана, а если там занято -- на ближайшее свободное место
|
||||
var cam = Camera.main;
|
||||
Vector2 center = cam != null ? (Vector2)cam.transform.position : Vector2.zero;
|
||||
if (BuildGrid.Instance.FindFreeNear(center, ghost.Footprint.size, out var cell)) ghost.MoveToCell(cell);
|
||||
else
|
||||
{
|
||||
var id = slot.occupyingBuilding != null ? slot.occupyingBuilding.GetComponent<PlacedBuildingId>() : null;
|
||||
UIKit.Toast("Участок занят" + (id != null ? ": " + id.buildingName : ""));
|
||||
return;
|
||||
ghost.MoveNear(center);
|
||||
UIKit.Toast("Свободного места под это здание нет");
|
||||
}
|
||||
OpenChooser(slot);
|
||||
CreatePlacePanel();
|
||||
}
|
||||
|
||||
// ---------------- участки ----------------
|
||||
|
||||
List<BuildSlot> FreeSlots()
|
||||
void CancelGhost()
|
||||
{
|
||||
var list = new List<BuildSlot>();
|
||||
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None)) if (!s.isOccupied) list.Add(s);
|
||||
return list;
|
||||
StopDrag();
|
||||
if (ghost != null) Destroy(ghost.gameObject);
|
||||
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>();
|
||||
if (sr == null) continue;
|
||||
bool show = IsActive && !s.isOccupied;
|
||||
if (sr.enabled != show) sr.enabled = show;
|
||||
if (show) sr.color = SlotColor;
|
||||
UIKit.Toast("Построено: " + d.buildingName);
|
||||
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
|
||||
}
|
||||
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();
|
||||
StopDrag();
|
||||
var bm = BuildManager.Instance;
|
||||
if (bm == null) return;
|
||||
GameObject root;
|
||||
var card = UIKit.Modal("Что построить?", out root);
|
||||
chooser = root;
|
||||
@@ -135,7 +239,7 @@ public class BuildModeController : MonoBehaviour
|
||||
card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero;
|
||||
|
||||
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
|
||||
UIKit.Text(card, "Участок: " + 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);
|
||||
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
|
||||
@@ -144,7 +248,7 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
// доступные сверху, закрытые по уровню Ратуши -- ниже
|
||||
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));
|
||||
|
||||
foreach (var data in all)
|
||||
@@ -152,6 +256,7 @@ public class BuildModeController : MonoBehaviour
|
||||
var d = data;
|
||||
bool locked = d.requiredTownHallLevel > th;
|
||||
bool afford = !locked && d.CanAfford();
|
||||
var size = BuildFootprint.Of(d).size;
|
||||
|
||||
var row = UIKit.Row(list, 120, 14, locked ? UIKit.Theme.CardDark : UIKit.Theme.Card, 10);
|
||||
var icon = UIKit.Box(row, d.icon != null ? Color.white : UIKit.Theme.Gray);
|
||||
@@ -160,26 +265,18 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
UIKit.Text(info, d.buildingName, 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,
|
||||
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
|
||||
|
||||
string label = locked ? "Закрыто" : (afford ? "Построить" : "Мало ресурсов");
|
||||
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (BuildManager.Instance.BuildOnSlot(d, slot))
|
||||
{
|
||||
UIKit.Toast("Построено: " + d.buildingName);
|
||||
CloseChooser();
|
||||
ApplySlotVisuals();
|
||||
if (FreeSlots().Count == 0) SetMode(false);
|
||||
}
|
||||
else UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost)));
|
||||
}, 230, 90, 26);
|
||||
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
|
||||
string label = locked ? "Закрыто" : "Выбрать";
|
||||
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
|
||||
b.interactable = !locked;
|
||||
}
|
||||
|
||||
UIKit.Button(card, "Отмена", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
|
||||
}
|
||||
|
||||
void CloseChooser()
|
||||
@@ -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);
|
||||
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.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.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -622,6 +622,12 @@ public class GameProgress : MonoBehaviour
|
||||
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
|
||||
static IArmy Army => GameServices.Get<IArmy>();
|
||||
|
||||
// Люди города -- модуль Населения. null -- модуль не подключён
|
||||
static IPopulation Population => GameServices.TryGet(out IPopulation p) ? p : null;
|
||||
|
||||
// Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма)
|
||||
public int Recruitable => Population != null ? Population.Recruitable : int.MaxValue;
|
||||
|
||||
public int TroopCount(int type) { return Army.GetTroops(type); }
|
||||
|
||||
public int TotalTroops
|
||||
@@ -629,20 +635,51 @@ public class GameProgress : MonoBehaviour
|
||||
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)
|
||||
{
|
||||
var def = GameData.Troops[index];
|
||||
if (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; }
|
||||
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
|
||||
if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; }
|
||||
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
|
||||
// Воин -- житель города (модуль Населения): берутся свободные жители, резерв не отдаётся
|
||||
var population = Population;
|
||||
if (population != null && population.Recruitable < count)
|
||||
{
|
||||
UIKit.Toast(population.Recruitable > 0
|
||||
? "Жителей хватит только на " + population.Recruitable + " воинов"
|
||||
: "Некого нанять: свободных жителей нет (резерв " + population.CivilianReserve + "). Снимите рабочих или стройте Дома");
|
||||
return;
|
||||
}
|
||||
if (!Pay(def.cost, count)) return;
|
||||
bool recruited = population != null && population.TryRecruit(count);
|
||||
|
||||
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
|
||||
if (start < Now) start = Now;
|
||||
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
|
||||
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end });
|
||||
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited });
|
||||
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
|
||||
MarkChanged();
|
||||
}
|
||||
@@ -681,7 +718,16 @@ public class GameProgress : MonoBehaviour
|
||||
{
|
||||
var o = Data.training[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.dailyTroops += o.count;
|
||||
Data.eventTroopRemainder += o.count;
|
||||
|
||||
@@ -24,6 +24,7 @@ public class TrainingOrder
|
||||
public int count;
|
||||
public long startTicks;
|
||||
public long endTicks;
|
||||
public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
|
||||
@@ -24,10 +24,32 @@ public class TroopsPanel : BasePanel
|
||||
ProfilePanel.Header(army, "Армия");
|
||||
ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0"));
|
||||
ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get<IArmy>().TroopsInMarches.ToString("N0"));
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
{
|
||||
// Воины берутся из свободных жителей (модуль Населения), резерв не нанять
|
||||
ProfilePanel.KV(army, "Обучаются в Казармах", population.InTraining.ToString("N0"));
|
||||
ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") +
|
||||
" (резерв " + population.CivilianReserve.ToString("N0") + ")");
|
||||
}
|
||||
ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0"));
|
||||
ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0"));
|
||||
UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26);
|
||||
|
||||
// --- Гарнизон: часть воинов охраняет город и в походы не уходит ---
|
||||
IArmy troopsArmy = GameServices.Get<IArmy>();
|
||||
var garrison = UIKit.Card(list, UIKit.Theme.CardDark);
|
||||
ProfilePanel.Header(garrison, "Гарнизон города");
|
||||
ProfilePanel.KV(garrison, "В гарнизоне", troopsArmy.GarrisonTotal.ToString("N0") + " из " + troopsArmy.SoldiersAtHome.ToString("N0"));
|
||||
ProfilePanel.KV(garrison, "Свободны для походов", troopsArmy.AvailableForMarch.ToString("N0"));
|
||||
UIKit.Text(garrison, "Гарнизон защищает город и не уходит в походы. Воины в нём не пропадают: всего воинов столько же.", 22, UIKit.Theme.TextDim);
|
||||
var gRow = UIKit.Row(garrison, 80, 10);
|
||||
GarrisonButton(gRow, troopsArmy, "0", 0);
|
||||
GarrisonButton(gRow, troopsArmy, "-10", troopsArmy.GarrisonTotal - 10);
|
||||
GarrisonButton(gRow, troopsArmy, "-1", troopsArmy.GarrisonTotal - 1);
|
||||
GarrisonButton(gRow, troopsArmy, "+1", troopsArmy.GarrisonTotal + 1);
|
||||
GarrisonButton(gRow, troopsArmy, "+10", troopsArmy.GarrisonTotal + 10);
|
||||
GarrisonButton(gRow, troopsArmy, "Все", troopsArmy.SoldiersAtHome);
|
||||
|
||||
// --- Нет казарм -- нанимать негде ---
|
||||
if (Barracks.Count == 0)
|
||||
{
|
||||
@@ -82,7 +104,7 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -90,13 +112,22 @@ public class TroopsPanel : BasePanel
|
||||
foreach (int amount in Amounts)
|
||||
{
|
||||
int a = amount;
|
||||
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize;
|
||||
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);
|
||||
UIKit.Size(b, -1, 80, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении
|
||||
static void GarrisonButton(Transform row, IArmy army, string label, int target)
|
||||
{
|
||||
target = Mathf.Clamp(target, 0, army.SoldiersAtHome);
|
||||
bool can = target != army.GarrisonTotal;
|
||||
var b = UIKit.Button(row, label, can ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { if (can) army.SetGarrison(target); }, -1, 80, 28);
|
||||
UIKit.Size(b, -1, 80, 1);
|
||||
}
|
||||
|
||||
protected override void Tick()
|
||||
{
|
||||
var d = GP.Data;
|
||||
|
||||
@@ -64,11 +64,23 @@ public abstract class BasePanel : MonoBehaviour
|
||||
t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24);
|
||||
}
|
||||
content = (RectTransform)t;
|
||||
WrapInSafeArea(content);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// Фон окна -- на весь экран, а содержимое -- в безопасной зоне: на iPhone левый край уходил под вырез камеры.
|
||||
// Content переезжает в растянутый контейнер с SafeArea и сохраняет свои отступы.
|
||||
void WrapInSafeArea(RectTransform rt)
|
||||
{
|
||||
if (rt.parent != transform) return; // уже обёрнут
|
||||
var safe = UIKit.Stretch(UIKit.Make("SafeArea", transform));
|
||||
safe.SetSiblingIndex(rt.GetSiblingIndex());
|
||||
safe.gameObject.AddComponent<SafeArea>().fitInsideParent = true; // окно -- не на весь экран
|
||||
rt.SetParent(safe, false);
|
||||
}
|
||||
|
||||
void Rebuild()
|
||||
{
|
||||
float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f;
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Text;
|
||||
//
|
||||
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
|
||||
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
|
||||
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
|
||||
public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
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[] garrison = new int[Types];
|
||||
private readonly List<MarchUnits> marches = new List<MarchUnits>();
|
||||
|
||||
// Секция "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)
|
||||
{
|
||||
if (type < 0 || type >= Types || count <= 0) return;
|
||||
@@ -67,41 +91,73 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
troops[i] -= l;
|
||||
lost += l;
|
||||
}
|
||||
ClampGarrison();
|
||||
if (lost > 0) Changed();
|
||||
return lost;
|
||||
}
|
||||
|
||||
public bool TakeForMarch(int marchId, int count)
|
||||
{
|
||||
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;
|
||||
|
||||
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
|
||||
marches.RemoveAll(m => m.marchId == marchId);
|
||||
marches.Add(new MarchUnits { marchId = marchId, units = taken });
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ReturnFromMarch(int marchId, int survivors)
|
||||
// Воины возвращаются только из известного похода, и только один раз: запись похода удаляется.
|
||||
// Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты
|
||||
// появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy.
|
||||
public bool ReturnFromMarch(int marchId, int survivors)
|
||||
{
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
if (entry == null)
|
||||
{
|
||||
troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой
|
||||
}
|
||||
else
|
||||
{
|
||||
int[] back = ArmyMath.SplitSurvivors(entry.units, survivors);
|
||||
for (int i = 0; i < Types; i++) troops[i] += back[i];
|
||||
marches.Remove(entry);
|
||||
}
|
||||
if (entry == null) return false;
|
||||
|
||||
int sent = 0;
|
||||
foreach (var u in entry.units) sent += u;
|
||||
int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся
|
||||
for (int i = 0; i < Types; i++) troops[i] += back[i];
|
||||
marches.Remove(entry);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public int[] GetMarchUnits(int marchId)
|
||||
{
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
return entry != null ? (int[])entry.units.Clone() : null;
|
||||
}
|
||||
|
||||
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
|
||||
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
|
||||
public bool ReturnUnits(int marchId, int[] survivorUnits)
|
||||
{
|
||||
MarchUnits entry = marches.Find(m => m.marchId == marchId);
|
||||
if (entry == null) return false;
|
||||
|
||||
for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++)
|
||||
troops[i] += Math.Max(0, Math.Min(survivorUnits[i], entry.units[i]));
|
||||
marches.Remove(entry);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ClampGarrison()
|
||||
{
|
||||
for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i]));
|
||||
}
|
||||
|
||||
void Changed()
|
||||
{
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal });
|
||||
}
|
||||
|
||||
// ---------------- Перенос из старого progress.json ----------------
|
||||
@@ -112,12 +168,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
if (legacyTroops != null)
|
||||
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
|
||||
marches.Clear();
|
||||
if (legacyMarches != null)
|
||||
{
|
||||
foreach (var m in legacyMarches)
|
||||
{
|
||||
if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись
|
||||
var units = new int[Types];
|
||||
for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]);
|
||||
marches.Add(new MarchUnits { marchId = m.Key, units = units });
|
||||
@@ -129,6 +187,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// ---------------- Сохранение (секция "army") ----------------
|
||||
// Модуль без Unity, поэтому формат -- простые строки:
|
||||
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
|
||||
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
|
||||
public string SaveKey => "army";
|
||||
@@ -137,6 +196,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("troops=").Append(Join(troops)).Append('\n');
|
||||
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
|
||||
foreach (var m in marches)
|
||||
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
|
||||
return sb.ToString();
|
||||
@@ -145,6 +205,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
public void Restore(string data)
|
||||
{
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
RestoredFromSave = true;
|
||||
|
||||
@@ -158,21 +219,29 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
int[] t = Parse(line.Substring("troops=".Length));
|
||||
Array.Copy(t, troops, Types);
|
||||
}
|
||||
else if (line.StartsWith("garrison="))
|
||||
{
|
||||
int[] g = Parse(line.Substring("garrison=".Length));
|
||||
Array.Copy(g, garrison, Types);
|
||||
}
|
||||
else if (line.StartsWith("march="))
|
||||
{
|
||||
string[] parts = line.Substring("march=".Length).Split(':');
|
||||
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id))
|
||||
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]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
|
||||
Changed();
|
||||
}
|
||||
|
||||
// Новая игра: войск нет, походов нет
|
||||
// Новая игра: войск нет, гарнизона и походов нет
|
||||
public void ResetState()
|
||||
{
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
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
|
||||
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
||||
//
|
||||
// Правила пока самые простые: побеждает тот, у кого больше солдат.
|
||||
// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся.
|
||||
// Правила (стартовые числа -- в BattleRules, утверждает команда):
|
||||
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
|
||||
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
|
||||
// бонус типа против врага взвешивается по составу врага;
|
||||
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
|
||||
// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог;
|
||||
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
|
||||
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
|
||||
// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие.
|
||||
public sealed class BattleSystem : IBattleSystem
|
||||
{
|
||||
private readonly BattleRules rules;
|
||||
private readonly int[] power;
|
||||
|
||||
public BattleRules Rules => rules;
|
||||
|
||||
public BattleSystem() : this(BattleRules.Default) { }
|
||||
|
||||
public BattleSystem(BattleRules rules)
|
||||
{
|
||||
this.rules = rules ?? BattleRules.Default;
|
||||
power = new int[TroopType.Count];
|
||||
var defaults = BattleRules.Default.UnitPower;
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
|
||||
? this.rules.UnitPower[i] : defaults[i];
|
||||
}
|
||||
|
||||
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
|
||||
public int Counter(int attackerType, int defenderType)
|
||||
{
|
||||
if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return rules.CounterPercent;
|
||||
if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return rules.CounterPercent;
|
||||
if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return rules.CounterPercent;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public BattleOutcome Resolve(BattleRequest request)
|
||||
{
|
||||
int attackers = request.Attackers;
|
||||
int defenders = request.Defenders;
|
||||
bool victory = attackers > defenders; // ничья -- в пользу защитника
|
||||
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2));
|
||||
}
|
||||
|
||||
// Проигравший теряет половину своего войска, победитель -- столько же
|
||||
int loserArmy = victory ? defenders : attackers;
|
||||
int losses = (loserArmy + 1) / 2;
|
||||
// Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается.
|
||||
public BattleForecast Forecast(BattleRequest request)
|
||||
{
|
||||
int r = rules.RandomPercent * 10;
|
||||
var f = new BattleForecast
|
||||
{
|
||||
AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue,
|
||||
AttackerLossesMin = int.MaxValue
|
||||
};
|
||||
bool anyWin = false, anyLoss = false;
|
||||
foreach (int attLuck in new[] { -r, r })
|
||||
foreach (int defLuck in new[] { -r, r })
|
||||
{
|
||||
BattleOutcome o = Fight(request, attLuck, defLuck);
|
||||
if (o.Victory) anyWin = true; else anyLoss = true;
|
||||
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, o.AttackerPower);
|
||||
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, o.AttackerPower);
|
||||
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, o.DefenderPower);
|
||||
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, o.DefenderPower);
|
||||
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, o.AttackerLosses);
|
||||
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, o.AttackerLosses);
|
||||
}
|
||||
f.Chance = !anyLoss ? BattleChance.Advantage : !anyWin ? BattleChance.HighRisk : BattleChance.Uncertain;
|
||||
return f;
|
||||
}
|
||||
|
||||
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille)
|
||||
{
|
||||
int[] att = Units(request.AttackerUnits, request.Attackers);
|
||||
int[] def = Units(request.DefenderUnits, request.Defenders);
|
||||
|
||||
int attCondition = ConditionPercent(request.AttackerConditions);
|
||||
int defCondition = ConditionPercent(request.DefenderConditions);
|
||||
long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attCondition, attLuckPermille);
|
||||
long defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
|
||||
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defCondition, defLuckPermille);
|
||||
|
||||
bool victory = attPower > defPower; // ничья -- в пользу защитника
|
||||
|
||||
// Потери в процентах: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя)
|
||||
long stronger = Math.Max(attPower, defPower);
|
||||
long weaker = Math.Min(attPower, defPower);
|
||||
int[] attDead = Losses(att, weaker, stronger, isLoser: !victory);
|
||||
int[] defDead = Losses(def, weaker, stronger, isLoser: victory);
|
||||
|
||||
// Раненых пока нет: доля раненых и погибших не утверждена (план от 27.10.2026)
|
||||
int[] attWounded = new int[TroopType.Count];
|
||||
int[] defWounded = new int[TroopType.Count];
|
||||
|
||||
var attAlive = new int[TroopType.Count];
|
||||
var defAlive = new int[TroopType.Count];
|
||||
var defOut = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
attAlive[i] = att[i] - attDead[i] - attWounded[i];
|
||||
defAlive[i] = def[i] - defDead[i] - defWounded[i];
|
||||
defOut[i] = defDead[i] + defWounded[i];
|
||||
}
|
||||
|
||||
return new BattleOutcome
|
||||
{
|
||||
Victory = victory,
|
||||
AttackerLosses = losses,
|
||||
DefenderLosses = losses,
|
||||
Survivors = attackers - losses
|
||||
BattleId = request.Seed,
|
||||
RulesVersion = rules.Version,
|
||||
AttackerLosses = Sum(attDead) + Sum(attWounded),
|
||||
DefenderLosses = Sum(defOut),
|
||||
Survivors = Sum(attAlive),
|
||||
AttackerPower = attPower,
|
||||
DefenderPower = defPower,
|
||||
AttackerLuckPermille = attLuckPermille,
|
||||
DefenderLuckPermille = defLuckPermille,
|
||||
AttackerConditionPercent = attCondition,
|
||||
DefenderConditionPercent = defCondition,
|
||||
AttackerUnitsBefore = att,
|
||||
DefenderUnitsBefore = def,
|
||||
AttackerSurvivorUnits = attAlive,
|
||||
DefenderSurvivorUnits = defAlive,
|
||||
AttackerDeadUnits = attDead,
|
||||
DefenderDeadUnits = defDead,
|
||||
AttackerWoundedUnits = attWounded,
|
||||
DefenderWoundedUnits = defWounded,
|
||||
DefenderLossUnits = defOut
|
||||
};
|
||||
}
|
||||
|
||||
// Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 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 bool WorldInputCaptured;
|
||||
|
||||
public static bool IsPointerOverUI()
|
||||
{
|
||||
if (EventSystem.current == null) return false;
|
||||
|
||||
@@ -46,14 +46,14 @@ public interface ILeveledBuilding
|
||||
int Level { get; set; }
|
||||
}
|
||||
|
||||
// ---------------- Население: жители, еда, прирост ----------------
|
||||
// ---------------- Население: жители, рабочие, еда, голод, прирост, найм ----------------
|
||||
public interface IPopulation
|
||||
{
|
||||
int Total { get; } // все люди города: жители + воины (в городе и в походах)
|
||||
int Total { get; } // все люди города: жители + обучаются в Казармах + воины (в городе и в походах)
|
||||
int Civilians { get; } // жители (не воины)
|
||||
int Workers { get; } // жители на работе (все профессии вместе)
|
||||
int Idle { get; } // остальные жители: Civilians - Workers
|
||||
int Workforce { get; } // сколько жителей вообще могут работать (взрослые; стартовый город -- 120)
|
||||
int Workforce { get; } // сколько жителей могут работать: все жители (общего ограничения нет, регламент 31.10)
|
||||
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
|
||||
int Soldiers { get; } // воины -- их хранит модуль Армии
|
||||
int HousingCap { get; } // предел населения: выше него прирост не идёт
|
||||
@@ -61,6 +61,20 @@ public interface IPopulation
|
||||
float FoodPerHour { get; } // сколько город съедает в час
|
||||
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
|
||||
bool IsStarving { get; } // еды не хватило на последний расчёт
|
||||
// Голодал ли город в момент unixTime (секунды). Для боёв, прошедших, пока игра была закрыта:
|
||||
// история хранится за последние MaxOfflineHours (48 ч), точность -- 10 минут (шаг расчёта).
|
||||
bool WasStarvingAt(long unixTime);
|
||||
|
||||
// Найм воинов (решение команды от 19.10.2026): воины берутся из свободных жителей, гражданский резерв не отдаётся.
|
||||
int CivilianReserve { get; } // гражданский резерв: столько свободных жителей в войско не взять
|
||||
int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина
|
||||
int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах
|
||||
bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет
|
||||
void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия (без проверки Армии)
|
||||
// Обучение закончено -- передать новобранцев в Армию одним действием: люди уходят из Населения, только если
|
||||
// Армия их действительно приняла (IArmy.AddTroops). false -- ничего не изменилось: столько новобранцев нет,
|
||||
// Армия не подключена или не приняла (новобранцы остаются в Казармах, никто не теряется и не дублируется).
|
||||
bool CompleteTraining(int troopType, int soldiers);
|
||||
|
||||
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
|
||||
void Tick(long unixNow);
|
||||
@@ -76,16 +90,29 @@ public interface IArmy
|
||||
{
|
||||
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
|
||||
int GetTroops(int type); // воинов этого типа в городе
|
||||
int SoldiersAtHome { get; } // всего воинов в городе
|
||||
int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном)
|
||||
int TroopsInMarches { get; } // воинов в походах
|
||||
|
||||
// Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется.
|
||||
int GarrisonTotal { get; }
|
||||
int GetGarrison(int type);
|
||||
int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон
|
||||
int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено
|
||||
|
||||
void AddTroops(int type, int count); // обучены / вернулись
|
||||
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
|
||||
|
||||
// Забрать воинов из города в поход (запоминает состав похода)
|
||||
// Забрать свободных воинов в поход (запоминает состав похода).
|
||||
// false -- ничего не взято: не хватает воинов или поход с таким номером уже есть.
|
||||
bool TakeForMarch(int marchId, int count);
|
||||
// Вернуть выживших из похода по тем же типам войск
|
||||
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 int Defenders;
|
||||
public const int Infantry = 0; // пехота
|
||||
public const int Archers = 1; // лучники
|
||||
public const int Cavalry = 2; // кавалерия
|
||||
public const int Siege = 3; // осадные
|
||||
public const int Count = 4;
|
||||
}
|
||||
|
||||
// Состояние войск стороны в бою (план от 27.10.2026). Штрафы применяются последовательно:
|
||||
// голод (-50%) и усталость (-20%) вместе оставляют 40% силы, а не 30%. Числа -- в правилах боя.
|
||||
[Flags]
|
||||
public enum BattleCondition
|
||||
{
|
||||
None = 0,
|
||||
Famine = 1, // голод в поселении на момент боя
|
||||
Fatigue = 2 // войско недавно сражалось (пока не выставляется: правила учёта усталости не утверждены)
|
||||
}
|
||||
|
||||
public struct BattleRequest
|
||||
{
|
||||
// Состав сторон по типам войск (TroopType). Если состав не задан (null) --
|
||||
// берётся число Attackers / Defenders, и все считаются пехотой.
|
||||
public int[] AttackerUnits;
|
||||
public int[] DefenderUnits;
|
||||
public int Attackers;
|
||||
public int Defenders;
|
||||
|
||||
public bool DefenderFortified; // защитники за стенами (город, замок)
|
||||
public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее
|
||||
public int DefenderBonusPercent;
|
||||
|
||||
// Состояния сторон: голод, усталость. Действуют последовательно (умножаются), см. BattleCondition
|
||||
public BattleCondition AttackerConditions;
|
||||
public BattleCondition DefenderConditions;
|
||||
|
||||
// Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками --
|
||||
// всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер.
|
||||
public long Seed;
|
||||
}
|
||||
|
||||
// Итог боя -- одинаково полный для обеих сторон. Все массивы -- по типам войск (TroopType).
|
||||
// По каждому типу: до боя = выжившие + погибшие + раненые.
|
||||
public struct BattleOutcome
|
||||
{
|
||||
public bool Victory; // победил атакующий
|
||||
public int AttackerLosses;
|
||||
public long BattleId; // = Seed запроса
|
||||
public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог
|
||||
|
||||
public int AttackerLosses; // выбыли из строя: погибшие + раненые
|
||||
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
|
||||
{
|
||||
BattleOutcome Resolve(BattleRequest request);
|
||||
BattleForecast Forecast(BattleRequest request); // Seed не нужен: перебираются крайние значения случайности
|
||||
}
|
||||
|
||||
// ---------------- Чат: общий мир и Круг ----------------
|
||||
|
||||
@@ -61,6 +61,7 @@ public struct TroopsChangedEvent
|
||||
{
|
||||
public int AtHome;
|
||||
public int InMarches;
|
||||
public int Garrison; // из AtHome -- в гарнизоне
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир ----------------
|
||||
@@ -86,6 +87,7 @@ public struct MarchReturnedEvent
|
||||
public bool Victory;
|
||||
public int Survivors;
|
||||
public int Sent; // сколько воинов уходило в поход
|
||||
public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все
|
||||
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
|
||||
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
|
||||
}
|
||||
|
||||
@@ -12,13 +12,18 @@ public sealed class ProductionRules
|
||||
public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30)
|
||||
public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено
|
||||
public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено
|
||||
|
||||
// Голод (регламент 31.10, ТЕСТ): добыча всех ресурсов, кроме еды, ×0.5. Еда -- без штрафа.
|
||||
// Штраф снимается сразу, как только город снова сыт (модуль Населения сообщает IsStarving).
|
||||
public double FamineNonFoodMultiplier = 0.5;
|
||||
}
|
||||
|
||||
// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md
|
||||
// Без Unity: правила те же, что будут на сервере.
|
||||
//
|
||||
// Ресурс производят люди, здание даёт только места и эффективность:
|
||||
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий.
|
||||
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий,
|
||||
// а при голоде всё, кроме еды, -- ещё × FamineNonFoodMultiplier (голод считает модуль Населения, IPopulation.IsStarving).
|
||||
// Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет.
|
||||
// Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт.
|
||||
//
|
||||
@@ -80,7 +85,10 @@ public sealed class ProductionService : IProduction, ISaveParticipant
|
||||
int j = (int)job;
|
||||
if (!GameServices.TryGet(out IPopulation population)) return 0;
|
||||
int working = Math.Min(population.GetWorkers(job), slots[j]);
|
||||
return working * rules.PerWorkerPerHour[j] * efficiency[j];
|
||||
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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// МОДУЛЬ НАСЕЛЕНИЯ. Документ: Docs/Modules/Population.md
|
||||
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
|
||||
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
|
||||
public sealed class PopulationRules
|
||||
@@ -21,6 +22,12 @@ public sealed class PopulationRules
|
||||
public int HousingPerTownHallLevel = 50;
|
||||
public int HousingPerHouseLevel = 50; // каждый уровень Дома (здание "Население")
|
||||
|
||||
// Найм воинов: воины набираются из свободных (не работающих) жителей, гражданский резерв не отдаётся.
|
||||
// Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1-2 -- 10, 3-5 -- 20, 6-10 -- 30.
|
||||
// Элемент [i] -- резерв на уровне Ратуши i+1; выше последнего уровня -- последнее число.
|
||||
public int[] CivilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
|
||||
public int PeoplePerSoldier = 1; // сколько жителей стоит один воин (EconomyConfig, раздел "Солдат")
|
||||
|
||||
// Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено.
|
||||
public int MaxOfflineHours = 48;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
@@ -7,7 +8,17 @@ using System.Text;
|
||||
//
|
||||
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (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 минут: раз в несколько секунд в игре,
|
||||
// при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же.
|
||||
@@ -28,6 +39,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды)
|
||||
long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу
|
||||
bool starving;
|
||||
int inTraining; // люди, взятые из жителей в Казармы и ещё не обученные
|
||||
// История голода: отрезки [начало, конец) в Unix-секундах, конец 0 -- голод идёт сейчас.
|
||||
// Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод считается на момент боя.
|
||||
readonly List<long[]> famine = new List<long[]>();
|
||||
bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение)
|
||||
|
||||
public PopulationService(PopulationRules rules)
|
||||
@@ -53,16 +68,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
|
||||
public int Idle => Math.Max(0, civilians - Workers);
|
||||
|
||||
// Доля жителей, способных работать: 120 из 270 в стартовом городе
|
||||
public int Workforce
|
||||
{
|
||||
get
|
||||
{
|
||||
int startCiv = Math.Max(1, rules.StartCivilians);
|
||||
double share = Math.Min(1.0, rules.StartWorkers / (double)startCiv);
|
||||
return (int)Math.Floor(civilians * share + Epsilon);
|
||||
}
|
||||
}
|
||||
// Работать может любой житель: общего ограничения нет (регламент 31.10).
|
||||
// Рабочих ограничивают свободные жители и рабочие места (IProduction.Slots).
|
||||
public int Workforce => civilians;
|
||||
|
||||
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
|
||||
{
|
||||
@@ -90,9 +156,33 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour;
|
||||
public bool IsStarving => starving;
|
||||
|
||||
public bool WasStarvingAt(long unixTime)
|
||||
{
|
||||
if (lastTickUnix == 0 || unixTime >= lastTickUnix) return starving; // ещё не посчитано -- как сейчас
|
||||
foreach (var f in famine)
|
||||
if (unixTime >= f[0] && (f[1] == 0 || unixTime < f[1])) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Голод начался или кончился в момент unix (вызывается из расчёта на каждом шаге)
|
||||
void RecordFamine(bool nowStarving, long unix)
|
||||
{
|
||||
long[] open = famine.Count > 0 && famine[famine.Count - 1][1] == 0 ? famine[famine.Count - 1] : null;
|
||||
if (nowStarving && open == null) famine.Add(new[] { unix, 0L });
|
||||
else if (!nowStarving && open != null) open[1] = unix;
|
||||
}
|
||||
|
||||
// Отрезки, закончившиеся раньше, чем 48 часов назад, Бою уже не понадобятся
|
||||
void PruneFamine(long unixNow)
|
||||
{
|
||||
long keepFrom = unixNow - (long)rules.MaxOfflineHours * SecondsPerHour;
|
||||
famine.RemoveAll(f => f[1] != 0 && f[1] <= keepFrom);
|
||||
}
|
||||
|
||||
double FoodPerHourExact(int soldiers)
|
||||
{
|
||||
return civilians * rules.FoodPerCivilianPerHour + soldiers * rules.FoodPerSoldierPerHour;
|
||||
// Обучающиеся в Казармах едят как воины
|
||||
return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour;
|
||||
}
|
||||
|
||||
public int GetWorkers(WorkJob job)
|
||||
@@ -106,7 +196,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
int j = (int)job;
|
||||
if (j < 0 || j >= WorkJobs.Count) return 0;
|
||||
|
||||
int limit = jobs[j] + FreeWorkers; // больше, чем есть свободных, не поставить
|
||||
int limit = jobs[j] + FreeWorkers; // только свободные жители: не воины, не новобранцы, не уже работающие
|
||||
int slots = SlotsFor(job);
|
||||
if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях
|
||||
int value = Math.Max(0, Math.Min(count, limit));
|
||||
@@ -146,13 +236,14 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
|
||||
if (elapsed > max) elapsed = max;
|
||||
|
||||
Simulate(elapsed);
|
||||
Simulate(unixNow - elapsed, elapsed);
|
||||
lastTickUnix = unixNow;
|
||||
PruneFamine(unixNow);
|
||||
}
|
||||
|
||||
// ---------------- Расчёт ----------------
|
||||
|
||||
void Simulate(long seconds)
|
||||
void Simulate(long startUnix, long seconds)
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего
|
||||
GameServices.TryGet(out IProduction production);
|
||||
@@ -162,6 +253,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
int soldiers = Soldiers;
|
||||
|
||||
long remaining = seconds;
|
||||
long stepStart = startUnix;
|
||||
while (remaining > 0)
|
||||
{
|
||||
long step = Math.Min(remaining, StepSeconds);
|
||||
@@ -195,11 +287,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
fed = false;
|
||||
}
|
||||
starving = !fed;
|
||||
RecordFamine(starving, stepStart); // голод начался (кончился) в этом куске -- запоминаем его начало
|
||||
stepStart += step;
|
||||
|
||||
// 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда.
|
||||
if (fed)
|
||||
{
|
||||
int room = HousingCap - (civilians + soldiers);
|
||||
int room = HousingCap - (civilians + inTraining + soldiers);
|
||||
if (room > 0)
|
||||
{
|
||||
growthProgress += rules.GrowthPerHour * hours;
|
||||
@@ -223,25 +317,19 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
|
||||
// ---------------- Рабочие: расстановка ----------------
|
||||
|
||||
// Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено.
|
||||
public int AutoFill(WorkJob job)
|
||||
{
|
||||
int slots = SlotsFor(job);
|
||||
int room = slots < 0 ? FreeWorkers : Math.Min(FreeWorkers, slots - jobs[(int)job]);
|
||||
if (room <= 0) return 0;
|
||||
SetWorkers(job, jobs[(int)job] + room);
|
||||
return room;
|
||||
}
|
||||
|
||||
// Расставить свободных по всем профессиям поровну (старое сохранение, первый запуск после этапа 3)
|
||||
// Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10).
|
||||
// Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false).
|
||||
// Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки
|
||||
// и улучшения остаются пустыми, пока игрок сам не поставит рабочих.
|
||||
public void AutoFillAll()
|
||||
{
|
||||
bool changed = false;
|
||||
bool progress = true;
|
||||
while (FreeWorkers > 0 && progress)
|
||||
int cap = Math.Max(Workers, rules.StartWorkers);
|
||||
while (FreeWorkers > 0 && Workers < cap && progress)
|
||||
{
|
||||
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);
|
||||
if (slots >= 0 && jobs[j] >= slots) continue;
|
||||
@@ -327,6 +415,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
foodOwed = 0;
|
||||
lastTickUnix = 0;
|
||||
starving = false;
|
||||
inTraining = 0;
|
||||
famine.Clear();
|
||||
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("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("starving=").Append(starving ? "1" : "0").Append('\n');
|
||||
sb.Append("training=").Append(inTraining.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
// famine=начало-конец;начало-конец (конец 0 -- голод идёт сейчас)
|
||||
var spans = new List<string>();
|
||||
foreach (var f in famine)
|
||||
spans.Add(f[0].ToString(CultureInfo.InvariantCulture) + "-" + f[1].ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append("famine=").Append(string.Join(";", spans.ToArray())).Append('\n');
|
||||
sb.Append("kit=").Append(startingKitGiven ? "1" : "0").Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
@@ -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);
|
||||
break;
|
||||
case "starving": starving = value == "1"; break;
|
||||
case "training": inTraining = Math.Max(0, ParseInt(value, 0)); break;
|
||||
case "famine":
|
||||
foreach (var span in value.Split(';'))
|
||||
{
|
||||
string[] se = span.Split('-');
|
||||
if (se.Length == 2
|
||||
&& long.TryParse(se[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fs)
|
||||
&& long.TryParse(se[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fe))
|
||||
famine.Add(new[] { fs, fe });
|
||||
}
|
||||
break;
|
||||
case "kit": startingKitGiven = value == "1"; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Рабочих не может быть больше, чем жителей (испорченное или очень старое сохранение).
|
||||
// Места в зданиях здесь не проверяем: секция Экономики может загрузиться позже -- это делает ClampJobs.
|
||||
int over = Workers - civilians;
|
||||
for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--)
|
||||
{
|
||||
int take = Math.Min(over, jobs[j]);
|
||||
jobs[j] -= take;
|
||||
over -= take;
|
||||
}
|
||||
}
|
||||
|
||||
// Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей.
|
||||
|
||||
@@ -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;
|
||||
|
||||
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
||||
// Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО
|
||||
// на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано.
|
||||
// Наведение на пустой слот -> подсветка (можно строить).
|
||||
// Наведение на занятый слот / мимо слотов -> подсветки нет, клик ничего не делает.
|
||||
// Список зданий и сама постройка.
|
||||
// Сейчас стройка свободная, на сетке: игрок сам выбирает место (BuildGrid, BuildGhost, BuildModeController),
|
||||
// а здание ставит BuildAt. Участки (BuildSlot) остались для старых сцен и сохранений -- BuildGrid их прячет.
|
||||
|
||||
public class BuildManager : MonoBehaviour
|
||||
{
|
||||
@@ -148,7 +147,47 @@ public class BuildManager : MonoBehaviour
|
||||
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 -- получилось.
|
||||
public bool BuildOnSlot(BuildingData data, BuildSlot slot)
|
||||
|
||||
@@ -17,6 +17,10 @@ public class BuildingData : ScriptableObject
|
||||
[Tooltip("Необходимый уровень ратуши")]
|
||||
public int requiredTownHallLevel = 1;
|
||||
|
||||
[Header("Размер на сетке стройки")]
|
||||
[Tooltip("Сколько клеток занимает здание (ширина × высота). 0 × 0 -- посчитать само по коллайдеру префаба")]
|
||||
public Vector2Int footprint;
|
||||
|
||||
[Header("Стоимость")]
|
||||
public ResourceCost[] cost;
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
||||
// Вешается на объект Ратуши, который уже стоит на карте.
|
||||
// Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager.
|
||||
// Тап по Ратуше открывает окно Ратуши (слой приложения подписан на Tapped), улучшение -- кнопкой в окне.
|
||||
|
||||
public class TownHall : MonoBehaviour
|
||||
{
|
||||
public static TownHall Instance { get; private set; }
|
||||
|
||||
// Тап по Ратуше: интерфейс откроет её окно (модуль про интерфейс не знает)
|
||||
public static event Action<TownHall> Tapped;
|
||||
|
||||
[Header("Уровень")]
|
||||
public int currentLevel = 1;
|
||||
public int maxLevel = 10;
|
||||
@@ -64,10 +69,18 @@ public class TownHall : MonoBehaviour
|
||||
EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel });
|
||||
Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!");
|
||||
}
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней
|
||||
Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение...");
|
||||
Upgrade();
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Отпустил палец на Ратуше, почти не сдвинув (а не тащил камеру) -- окно Ратуши. Раньше тап сразу улучшал
|
||||
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);
|
||||
}
|
||||
|
||||
// Решение команды от 19.10.2026: один солдат -- в одном месте. 30 солдат, 10 в гарнизон -> 20 доступны, всего 30.
|
||||
[Test]
|
||||
public void ArmyService_Garrison_OneSoldierInOnePlace()
|
||||
{
|
||||
var army = new ArmyService();
|
||||
army.AddTroops(0, 20); army.AddTroops(1, 10);
|
||||
|
||||
Assert.AreEqual(10, army.SetGarrison(10));
|
||||
Assert.AreEqual(10, army.GarrisonTotal);
|
||||
Assert.AreEqual(20, army.AvailableForMarch);
|
||||
Assert.AreEqual(30, army.SoldiersAtHome, "всего воинов столько же");
|
||||
Assert.AreEqual(7, army.GetGarrison(0), "гарнизон по составу: 2/3 пехоты");
|
||||
Assert.AreEqual(3, army.GetGarrison(1), "и 1/3 лучников");
|
||||
|
||||
Assert.IsFalse(army.TakeForMarch(1, 21), "гарнизон в поход не уходит");
|
||||
Assert.IsTrue(army.TakeForMarch(1, 20));
|
||||
Assert.AreEqual(0, army.AvailableForMarch);
|
||||
Assert.AreEqual(10, army.GarrisonTotal, "гарнизон остался в городе");
|
||||
Assert.AreEqual(30, army.SoldiersAtHome + army.TroopsInMarches);
|
||||
|
||||
Assert.AreEqual(10, army.SetGarrison(99), "гарнизон не больше воинов в городе");
|
||||
|
||||
army.LoseFraction(0.5);
|
||||
Assert.LessOrEqual(army.GarrisonTotal, army.SoldiersAtHome, "после потерь гарнизон не больше войск");
|
||||
for (int i = 0; i < 4; i++) Assert.LessOrEqual(army.GetGarrison(i), army.GetTroops(i));
|
||||
|
||||
var loaded = new ArmyService();
|
||||
loaded.Restore(army.Capture());
|
||||
Assert.AreEqual(army.GarrisonTotal, loaded.GarrisonTotal, "гарнизон сохраняется");
|
||||
|
||||
loaded.Restore("troops=5,0,0,0\n"); // старое сохранение без строки гарнизона
|
||||
Assert.AreEqual(0, loaded.GarrisonTotal);
|
||||
Assert.AreEqual(5, loaded.AvailableForMarch);
|
||||
}
|
||||
|
||||
// ---------------- Армия: безопасная отправка и возвращение (ревью 27.10.2026, п. 2.1-2.2) ----------------
|
||||
|
||||
[Test]
|
||||
public void ArmyService_UnknownMarch_ReturnsNobody()
|
||||
{
|
||||
var army = new ArmyService();
|
||||
army.AddTroops(0, 5);
|
||||
|
||||
Assert.IsFalse(army.ReturnFromMarch(42, 10), "неизвестный поход");
|
||||
Assert.IsFalse(army.ReturnUnits(42, new[] { 3, 3, 3, 3 }), "неизвестный поход");
|
||||
Assert.AreEqual(5, army.SoldiersAtHome, "из ниоткуда никто не появился");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArmyService_SameMarch_IsCreditedOnlyOnce()
|
||||
{
|
||||
var army = new ArmyService();
|
||||
army.AddTroops(0, 10); army.AddTroops(1, 10);
|
||||
|
||||
Assert.IsTrue(army.TakeForMarch(1, 10));
|
||||
Assert.IsTrue(army.ReturnUnits(1, army.GetMarchUnits(1)));
|
||||
Assert.IsFalse(army.ReturnUnits(1, new[] { 5, 5, 0, 0 }), "повторное событие возвращения");
|
||||
Assert.IsFalse(army.ReturnFromMarch(1, 10), "и по другому пути -- тоже нет");
|
||||
Assert.AreEqual(20, army.SoldiersAtHome + army.TroopsInMarches);
|
||||
|
||||
Assert.IsTrue(army.TakeForMarch(2, 10));
|
||||
Assert.IsTrue(army.ReturnFromMarch(2, 99), "вернулось больше, чем ушло");
|
||||
Assert.AreEqual(20, army.SoldiersAtHome, "больше, чем ушло, не вернётся");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ArmyService_TakeForMarch_SameIdTwice_IsRejected()
|
||||
{
|
||||
var army = new ArmyService();
|
||||
army.AddTroops(0, 20);
|
||||
|
||||
Assert.IsTrue(army.TakeForMarch(7, 5));
|
||||
Assert.IsFalse(army.TakeForMarch(7, 5), "второй поход с тем же номером затёр бы первый");
|
||||
Assert.AreEqual(15, army.SoldiersAtHome);
|
||||
Assert.AreEqual(5, army.TroopsInMarches);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void World_CancelMarch_OnlyBeforeBattle()
|
||||
{
|
||||
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
||||
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _);
|
||||
Assert.IsTrue(world.CancelMarch(m.id));
|
||||
Assert.AreEqual(0, world.Marches.Count);
|
||||
|
||||
MarchData fought = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, 0, out _);
|
||||
world.Advance(fought.arriveMs, new List<WorldEvent>()); // бой прошёл, войско идёт домой
|
||||
Assert.IsFalse(world.CancelMarch(fought.id), "после боя поход не отменяется");
|
||||
}
|
||||
|
||||
// ---------------- Боевая система ----------------
|
||||
|
||||
[Test]
|
||||
public void Battle_MoreSoldiersWins_TieGoesToDefender()
|
||||
{
|
||||
var battle = new BattleSystem();
|
||||
var battle = new BattleSystem(BattleRules.NoRandom);
|
||||
Assert.IsTrue(battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Victory);
|
||||
Assert.IsFalse(battle.Resolve(new BattleRequest { Attackers = 20, Defenders = 20 }).Victory);
|
||||
Assert.AreEqual(20, battle.Resolve(new BattleRequest { Attackers = 30, Defenders = 20 }).Survivors);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Battle_Counters_ArchersBeatInfantry_InfantryBeatsCavalry()
|
||||
{
|
||||
var battle = new BattleSystem(BattleRules.NoRandom);
|
||||
// Одинаковая "сырая" сила: 25 лучников x5 = 125 против ~31 пехоты x4 = 124
|
||||
var archersVsInfantry = battle.Resolve(new BattleRequest
|
||||
{ AttackerUnits = new[] { 0, 25, 0, 0 }, DefenderUnits = new[] { 32, 0, 0, 0 } });
|
||||
Assert.IsTrue(archersVsInfantry.Victory, "лучники с контрой бьют чуть более сильную пехоту");
|
||||
|
||||
var cavalryVsInfantry = battle.Resolve(new BattleRequest
|
||||
{ AttackerUnits = new[] { 0, 0, 14, 0 }, DefenderUnits = new[] { 30, 0, 0, 0 } });
|
||||
Assert.IsFalse(cavalryVsInfantry.Victory, "пехота с контрой держит чуть более сильную кавалерию");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Battle_WallsHelpDefenders_SiegeBreaksWalls()
|
||||
{
|
||||
var battle = new BattleSystem(BattleRules.NoRandom);
|
||||
var field = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
|
||||
var walls = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
|
||||
Assert.IsTrue(field.Victory);
|
||||
Assert.IsFalse(walls.Victory, "те же войска за стенами устояли");
|
||||
|
||||
var siege = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true });
|
||||
var siegeField = battle.Resolve(new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 6 }, DefenderUnits = new[] { 20, 0, 0, 0 } });
|
||||
Assert.Greater(siege.AttackerPower, siegeField.AttackerPower, "осадные сильнее против стен, чем в поле");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Battle_LossesPerType_NeverExceedUnits_AndSumUp()
|
||||
{
|
||||
var battle = new BattleSystem(BattleRules.NoRandom);
|
||||
var rnd = new Random(7);
|
||||
for (int run = 0; run < 500; run++)
|
||||
{
|
||||
var att = new int[4]; var def = new int[4];
|
||||
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 30); def[i] = rnd.Next(0, 30); }
|
||||
var o = battle.Resolve(new BattleRequest { AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0 });
|
||||
|
||||
int survivors = 0, defLost = 0;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]));
|
||||
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]));
|
||||
survivors += o.AttackerSurvivorUnits[i]; defLost += o.DefenderLossUnits[i];
|
||||
}
|
||||
Assert.AreEqual(o.Survivors, survivors);
|
||||
Assert.AreEqual(o.DefenderLosses, defLost);
|
||||
Assert.AreEqual(att[0] + att[1] + att[2] + att[3] - o.AttackerLosses, o.Survivors);
|
||||
}
|
||||
}
|
||||
|
||||
// Малые отряды, пустые стороны, ничья, предельные бонусы (ревью 27.10.2026, п. 2.4).
|
||||
// Проверяем только то, что не зависит от будущей формулы потерь: исход и арифметику.
|
||||
// Сколько теряет одиночка-проигравший -- решение команды (правило округления), тест его не фиксирует.
|
||||
[Test]
|
||||
public void Battle_EdgeCases_OutcomeAndArithmetic()
|
||||
{
|
||||
var b = new BattleSystem(BattleRules.NoRandom);
|
||||
var cases = new[]
|
||||
{
|
||||
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 1 x 1
|
||||
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } }, // 1 x 10
|
||||
new BattleRequest { AttackerUnits = new[] { 10, 0, 0, 0 }, DefenderUnits = new[] { 1, 0, 0, 0 } }, // 10 x 1
|
||||
new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 0 x 0
|
||||
new BattleRequest { AttackerUnits = new[] { 0, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 } }, // 0 x 5
|
||||
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 0, 0, 0, 0 } }, // 5 x 0
|
||||
new BattleRequest { AttackerUnits = new[] { 3, 2, 1, 1 }, DefenderUnits = new[] { 2, 2, 2, 1 }, DefenderFortified = true },
|
||||
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -100 },
|
||||
new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 5, 0, 0, 0 }, AttackerBonusPercent = -500, DefenderBonusPercent = -500 },
|
||||
new BattleRequest { AttackerUnits = new[] { 1, 0, 0, 0 }, DefenderUnits = new[] { 50, 50, 50, 50 }, AttackerBonusPercent = 100000 },
|
||||
new BattleRequest { Attackers = 7, Defenders = 7 }, // старый бой числом
|
||||
};
|
||||
|
||||
foreach (var r in cases)
|
||||
{
|
||||
BattleOutcome o = b.Resolve(r);
|
||||
int[] att = r.AttackerUnits ?? new[] { r.Attackers, 0, 0, 0 };
|
||||
int[] def = r.DefenderUnits ?? new[] { r.Defenders, 0, 0, 0 };
|
||||
|
||||
Assert.GreaterOrEqual(o.AttackerPower, 0);
|
||||
Assert.GreaterOrEqual(o.DefenderPower, 0);
|
||||
Assert.AreEqual(o.AttackerPower > o.DefenderPower, o.Victory, "побеждает большая сила, ничья -- защитнику");
|
||||
|
||||
int survivors = 0, defLost = 0;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
Assert.That(o.AttackerSurvivorUnits[i], Is.InRange(0, att[i]), "выжившие атакующего в пределах состава");
|
||||
Assert.That(o.DefenderLossUnits[i], Is.InRange(0, def[i]), "потери защитника в пределах состава");
|
||||
survivors += o.AttackerSurvivorUnits[i];
|
||||
defLost += o.DefenderLossUnits[i];
|
||||
}
|
||||
Assert.AreEqual(o.Survivors, survivors);
|
||||
Assert.AreEqual(o.DefenderLosses, defLost);
|
||||
Assert.AreEqual(att[0] + att[1] + att[2] + att[3], o.Survivors + o.AttackerLosses, "выжившие + погибшие = ушедшие");
|
||||
}
|
||||
|
||||
// Ничья 1 x 1 -- победа защитника; пустая сторона никого не теряет
|
||||
Assert.IsFalse(b.Resolve(cases[0]).Victory);
|
||||
Assert.IsFalse(b.Resolve(cases[3]).Victory);
|
||||
Assert.AreEqual(0, b.Resolve(cases[3]).AttackerLosses + b.Resolve(cases[3]).DefenderLosses);
|
||||
Assert.IsTrue(b.Resolve(cases[5]).Victory, "на пустую цель -- победа");
|
||||
Assert.AreEqual(0, b.Resolve(cases[5]).AttackerLosses, "на пустую цель -- без потерь");
|
||||
Assert.IsFalse(b.Resolve(cases[8]).Victory, "обе силы обнулились -- ничья, защитнику");
|
||||
}
|
||||
|
||||
// ---------------- Боевой цикл (этап 2 плана от 27.10.2026) ----------------
|
||||
|
||||
static readonly BattleRequest SampleBattle = new BattleRequest
|
||||
{ AttackerUnits = new[] { 12, 8, 6, 4 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true };
|
||||
|
||||
[Test]
|
||||
public void Battle_Luck_IsReproducible_AndWithinFivePercent()
|
||||
{
|
||||
var b = new BattleSystem();
|
||||
bool plus = false, minus = false, differs = false;
|
||||
BattleOutcome first = default;
|
||||
for (long seed = 1; seed <= 2000; seed++)
|
||||
{
|
||||
var r = SampleBattle; r.Seed = seed;
|
||||
BattleOutcome o = b.Resolve(r);
|
||||
BattleOutcome again = b.Resolve(r);
|
||||
Assert.AreEqual(o.AttackerPower, again.AttackerPower, "тот же номер боя -- тот же итог");
|
||||
Assert.AreEqual(o.Survivors, again.Survivors);
|
||||
Assert.AreEqual(seed, o.BattleId);
|
||||
Assert.That(o.AttackerLuckPermille, Is.InRange(-50, 50));
|
||||
Assert.That(o.DefenderLuckPermille, Is.InRange(-50, 50));
|
||||
plus |= o.AttackerLuckPermille > 0;
|
||||
minus |= o.AttackerLuckPermille < 0;
|
||||
if (seed == 1) first = o; else differs |= o.AttackerPower != first.AttackerPower;
|
||||
}
|
||||
Assert.IsTrue(plus && minus && differs, "случайность бывает в обе стороны и меняется от боя к бою");
|
||||
|
||||
var noLuck = new BattleSystem(BattleRules.NoRandom);
|
||||
var req = SampleBattle; req.Seed = 777;
|
||||
Assert.AreEqual(0, noLuck.Resolve(req).AttackerLuckPermille, "RandomPercent = 0 -- без случайности");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Battle_Forecast_ShowsRisk_AndNeverContradictsRealBattle()
|
||||
{
|
||||
var b = new BattleSystem();
|
||||
var strong = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 10, 0, 0, 0 } };
|
||||
var weak = new BattleRequest { AttackerUnits = new[] { 5, 0, 0, 0 }, DefenderUnits = new[] { 50, 0, 0, 0 } };
|
||||
var even = new BattleRequest { AttackerUnits = new[] { 20, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 } };
|
||||
Assert.AreEqual(BattleChance.Advantage, b.Forecast(strong).Chance);
|
||||
Assert.AreEqual(BattleChance.HighRisk, b.Forecast(weak).Chance);
|
||||
Assert.AreEqual(BattleChance.Uncertain, b.Forecast(even).Chance);
|
||||
|
||||
bool evenWon = false, evenLost = false;
|
||||
foreach (var req in new[] { strong, weak, even, SampleBattle })
|
||||
{
|
||||
BattleForecast f = b.Forecast(req);
|
||||
for (long seed = 1; seed <= 300; seed++)
|
||||
{
|
||||
var r = req; r.Seed = seed;
|
||||
BattleOutcome o = b.Resolve(r);
|
||||
if (f.Chance == BattleChance.Advantage) Assert.IsTrue(o.Victory, "преимущество -- победа при любой удаче");
|
||||
if (f.Chance == BattleChance.HighRisk) Assert.IsFalse(o.Victory, "высокий риск -- поражение при любой удаче");
|
||||
Assert.That(o.AttackerPower, Is.InRange(f.AttackerPowerMin, f.AttackerPowerMax));
|
||||
Assert.That(o.DefenderPower, Is.InRange(f.DefenderPowerMin, f.DefenderPowerMax));
|
||||
Assert.That(o.AttackerLosses, Is.InRange(f.AttackerLossesMin, f.AttackerLossesMax), "потери в пределах прогноза");
|
||||
if (req.AttackerUnits == even.AttackerUnits) { evenWon |= o.Victory; evenLost |= !o.Victory; }
|
||||
}
|
||||
}
|
||||
Assert.IsTrue(evenWon && evenLost, "равный бой решает удача: бывает и победа, и поражение");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Battle_Outcome_IsSymmetric_AndAddsUpPerType()
|
||||
{
|
||||
var b = new BattleSystem();
|
||||
var rnd = new Random(11);
|
||||
for (int run = 0; run < 300; run++)
|
||||
{
|
||||
var att = new int[4]; var def = new int[4];
|
||||
for (int i = 0; i < 4; i++) { att[i] = rnd.Next(0, 25); def[i] = rnd.Next(0, 25); }
|
||||
BattleOutcome o = b.Resolve(new BattleRequest
|
||||
{ AttackerUnits = att, DefenderUnits = def, DefenderFortified = rnd.Next(2) == 0, Seed = run });
|
||||
|
||||
int attOut = 0, defOut = 0;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
Assert.AreEqual(att[i], o.AttackerUnitsBefore[i]);
|
||||
Assert.AreEqual(def[i], o.DefenderUnitsBefore[i]);
|
||||
Assert.AreEqual(att[i], o.AttackerSurvivorUnits[i] + o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i], "атакующий: до = выжившие + погибшие + раненые");
|
||||
Assert.AreEqual(def[i], o.DefenderSurvivorUnits[i] + o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], "защитник: то же");
|
||||
Assert.AreEqual(0, o.AttackerWoundedUnits[i] + o.DefenderWoundedUnits[i], "раненых пока нет: доля не утверждена");
|
||||
Assert.GreaterOrEqual(o.AttackerSurvivorUnits[i], 0);
|
||||
Assert.GreaterOrEqual(o.DefenderSurvivorUnits[i], 0);
|
||||
Assert.AreEqual(o.DefenderDeadUnits[i] + o.DefenderWoundedUnits[i], o.DefenderLossUnits[i]);
|
||||
attOut += o.AttackerDeadUnits[i] + o.AttackerWoundedUnits[i];
|
||||
defOut += o.DefenderLossUnits[i];
|
||||
}
|
||||
Assert.AreEqual(o.AttackerLosses, attOut);
|
||||
Assert.AreEqual(o.DefenderLosses, defOut);
|
||||
Assert.AreEqual(BattleRules.Default.Version, o.RulesVersion);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Battle_Rules_ComeFromConfig()
|
||||
{
|
||||
var req = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true };
|
||||
Assert.IsFalse(new BattleSystem(BattleRules.NoRandom).Resolve(req).Victory, "стены +20% держат");
|
||||
|
||||
var noWalls = BattleRules.NoRandom;
|
||||
noWalls.WallsPercent = 0;
|
||||
noWalls.Version = 2;
|
||||
BattleOutcome o = new BattleSystem(noWalls).Resolve(req);
|
||||
Assert.IsTrue(o.Victory, "без стен те же войска побеждают");
|
||||
Assert.AreEqual(2, o.RulesVersion, "итог помнит версию правил");
|
||||
}
|
||||
|
||||
// Голод -50%, усталость -20%, вместе -- 40% (план от 27.10.2026)
|
||||
[Test]
|
||||
public void Battle_Famine_HalvesPower_AndStacksWithFatigue()
|
||||
{
|
||||
var b = new BattleSystem(BattleRules.NoRandom);
|
||||
Assert.AreEqual(100, b.ConditionPercent(BattleCondition.None));
|
||||
Assert.AreEqual(50, b.ConditionPercent(BattleCondition.Famine));
|
||||
Assert.AreEqual(80, b.ConditionPercent(BattleCondition.Fatigue));
|
||||
Assert.AreEqual(40, b.ConditionPercent(BattleCondition.Famine | BattleCondition.Fatigue), "последовательно: 50% x 80%, а не 30%");
|
||||
|
||||
// 40 пехоты берут замок Змея Горыныча, голодные -- нет
|
||||
var req = new BattleRequest { AttackerUnits = new[] { 40, 0, 0, 0 }, DefenderUnits = new[] { 8, 6, 4, 2 }, DefenderFortified = true };
|
||||
BattleOutcome fed = b.Resolve(req);
|
||||
req.AttackerConditions = BattleCondition.Famine;
|
||||
BattleOutcome hungry = b.Resolve(req);
|
||||
Assert.IsTrue(fed.Victory);
|
||||
Assert.IsFalse(hungry.Victory, "голодное войско слабее вдвое");
|
||||
Assert.AreEqual(fed.AttackerPower / 2, hungry.AttackerPower);
|
||||
Assert.AreEqual(fed.DefenderPower, hungry.DefenderPower, "голод нападающего не трогает защитника");
|
||||
Assert.AreEqual(50, hungry.AttackerConditionPercent);
|
||||
Assert.AreEqual(100, hungry.DefenderConditionPercent);
|
||||
|
||||
// Голод защитника работает так же
|
||||
var defHungry = new BattleRequest { AttackerUnits = new[] { 22, 0, 0, 0 }, DefenderUnits = new[] { 20, 0, 0, 0 }, DefenderFortified = true };
|
||||
Assert.IsFalse(b.Resolve(defHungry).Victory);
|
||||
defHungry.DefenderConditions = BattleCondition.Famine;
|
||||
Assert.IsTrue(b.Resolve(defHungry).Victory, "голодный гарнизон не удержал стены");
|
||||
|
||||
// Прогноз учитывает голод
|
||||
Assert.AreEqual(BattleChance.HighRisk, new BattleSystem().Forecast(req).Chance);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void World_FamineAtBattleTime_WeakensMarch()
|
||||
{
|
||||
MarchData Run(BattleCondition conditions)
|
||||
{
|
||||
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom));
|
||||
world.AttackerConditions = _ => conditions;
|
||||
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _);
|
||||
world.Advance(m.arriveMs, new List<WorldEvent>());
|
||||
return m;
|
||||
}
|
||||
MarchData fed = Run(BattleCondition.None), hungry = Run(BattleCondition.Famine);
|
||||
Assert.IsTrue(fed.victory);
|
||||
Assert.AreEqual(100, fed.attackerConditionPercent);
|
||||
Assert.IsFalse(hungry.victory, "голод в момент боя -- поражение");
|
||||
Assert.AreEqual(50, hungry.attackerConditionPercent);
|
||||
Assert.AreEqual((int)BattleCondition.Famine, hungry.attackerConditions);
|
||||
|
||||
// Не задано, кто голодает (сервер ещё не подключён, старый код) -- без штрафов
|
||||
var plain = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem(BattleRules.NoRandom));
|
||||
MarchData p = plain.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 40, 0, out _);
|
||||
plain.Advance(p.arriveMs, new List<WorldEvent>());
|
||||
Assert.AreEqual(100, p.attackerConditionPercent);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void World_MarchBattle_IsReproducible()
|
||||
{
|
||||
MarchData Run()
|
||||
{
|
||||
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
||||
MarchData m = world.StartMarch("p", WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 30, 1000, out _);
|
||||
m.units = new[] { 12, 8, 6, 4 };
|
||||
world.Advance(m.arriveMs, new List<WorldEvent>());
|
||||
return m;
|
||||
}
|
||||
MarchData a = Run(), b = Run();
|
||||
Assert.AreNotEqual(0, a.battleSeed);
|
||||
Assert.AreEqual(a.battleSeed, b.battleSeed, "тот же поход -- тот же номер боя");
|
||||
Assert.AreEqual(a.attackerPower, b.attackerPower);
|
||||
Assert.AreEqual(a.attackerLuck, b.attackerLuck);
|
||||
Assert.AreEqual(a.survivors, b.survivors);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void World_GarrisonComposition_SumsToGarrison_AndIsStable()
|
||||
{
|
||||
foreach (var node in WorldMapLayout.CreateDefault())
|
||||
{
|
||||
int[] a = WorldMapLayout.GarrisonUnits(node);
|
||||
int[] b = WorldMapLayout.GarrisonUnits(node);
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 4; i++) { Assert.GreaterOrEqual(a[i], 0); Assert.AreEqual(a[i], b[i]); sum += a[i]; }
|
||||
Assert.AreEqual(node.garrison, sum, node.displayName);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир: лагеря разбойников ----------------
|
||||
|
||||
[Test]
|
||||
public void World_Camps_AreSpreadOut_FightInField_WithoutSiege()
|
||||
{
|
||||
var nodes = WorldMapLayout.CreateDefault();
|
||||
int camps = 0;
|
||||
foreach (var camp in nodes)
|
||||
{
|
||||
if (camp.type != MapNodeType.Camp) continue;
|
||||
camps++;
|
||||
Assert.IsFalse(WorldMapLayout.IsFortified(camp), "лагерь -- бой в поле");
|
||||
Assert.AreEqual(0, WorldMapLayout.GarrisonUnits(camp)[TroopType.Siege], "у разбойников нет осадных");
|
||||
Assert.That(camp.level, Is.InRange(1, WorldMapLayout.CampLevels));
|
||||
foreach (var other in nodes)
|
||||
{
|
||||
if (other == camp) continue;
|
||||
int min = other.type == MapNodeType.BossCastle ? 4500 : 2600;
|
||||
Assert.GreaterOrEqual(WorldMapLayout.Distance(camp, other), min - 1, camp.id + " слишком близко к " + other.id);
|
||||
}
|
||||
}
|
||||
Assert.AreEqual(WorldMapLayout.CampCount, camps);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void World_CampCleared_SecondMarchFindsItEmpty_ThenItRespawns()
|
||||
{
|
||||
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
||||
const string camp = "camp_1"; // 1 уровень, 6..9 разбойников
|
||||
MarchData first = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 0, out string e1);
|
||||
MarchData second = world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 1, out string e2);
|
||||
Assert.IsNotNull(first, e1);
|
||||
Assert.IsNotNull(second, e2);
|
||||
|
||||
world.Advance(10 * 60_000, new List<WorldEvent>()); // через 10 минут оба похода закончились
|
||||
Assert.IsTrue(first.victory);
|
||||
Assert.IsNotEmpty(first.loot);
|
||||
Assert.IsTrue(second.targetEmpty, "второе войско пришло к уже разбитому лагерю");
|
||||
Assert.AreEqual(30, second.survivors);
|
||||
Assert.IsEmpty(second.loot);
|
||||
|
||||
Assert.IsTrue(world.IsCampCleared(camp, 10 * 60_000));
|
||||
Assert.IsNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, 10 * 60_000, out _), "разбитый лагерь не атакуется");
|
||||
|
||||
// Сохранение: другой мир с теми же данными помнит разбитый лагерь
|
||||
var loaded = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
||||
loaded.LoadCampStates(world.GetCampStates());
|
||||
Assert.IsTrue(loaded.IsCampCleared(camp, 10 * 60_000));
|
||||
|
||||
long respawn = first.arriveMs + CampRules.RespawnMs;
|
||||
Assert.IsFalse(world.IsCampCleared(camp, respawn), "лагерь появился снова");
|
||||
Assert.IsNotNull(world.StartMarch("p", WorldMapLayout.PlayerCityId, camp, 30, respawn, out _));
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир: поход до конца, в том числе офлайн ----------------
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -69,9 +69,9 @@ public class PopulationTests
|
||||
|
||||
Assert.AreEqual(300, p.Total);
|
||||
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(120, p.FreeWorkers);
|
||||
Assert.AreEqual(270, p.FreeWorkers);
|
||||
Assert.AreEqual(30, p.Soldiers);
|
||||
Assert.AreEqual(33f, p.FoodPerHour, 0.001f);
|
||||
|
||||
@@ -118,6 +118,274 @@ public class PopulationTests
|
||||
Assert.IsTrue(p.IsStarving);
|
||||
}
|
||||
|
||||
// ---------------- Найм воинов из жителей (решение команды от 19.10.2026) ----------------
|
||||
|
||||
[Test]
|
||||
public void Recruit_150Free_Reserve30_Max120()
|
||||
{
|
||||
settlement.TownHallLevel = 6; // резерв 30 -- с 6 уровня Ратуши
|
||||
var p = StartCity();
|
||||
p.SetWorkers(WorkJob.Farmer, 120); // 120 рабочих -- свободных 150
|
||||
Assert.AreEqual(150, p.Idle);
|
||||
Assert.AreEqual(30, p.CivilianReserve);
|
||||
Assert.AreEqual(120, p.Recruitable, "пример команды: 150 свободных, резерв 30 -> 120");
|
||||
Assert.IsFalse(p.TryRecruit(121));
|
||||
Assert.IsTrue(p.TryRecruit(120));
|
||||
Assert.AreEqual(0, p.Recruitable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recruit_TakesCivilians_TotalStaysTheSame()
|
||||
{
|
||||
var p = StartCity();
|
||||
Assert.IsTrue(p.TryRecruit(20));
|
||||
Assert.AreEqual(250, p.Civilians);
|
||||
Assert.AreEqual(20, p.InTraining);
|
||||
Assert.AreEqual(300, p.Total, "житель стал новобранцем -- людей столько же");
|
||||
|
||||
p.RecruitsTrained(20);
|
||||
army.AddTroops(0, 20); // Казармы выпускают воинов в Армию
|
||||
Assert.AreEqual(0, p.InTraining);
|
||||
Assert.AreEqual(50, p.Soldiers);
|
||||
Assert.AreEqual(300, p.Total, "новобранцы не посчитаны дважды");
|
||||
}
|
||||
|
||||
// ---------------- Группы людей не пересекаются (регламент 31.10) ----------------
|
||||
|
||||
static void AssertGroupsAddUp(PopulationService p, string when)
|
||||
{
|
||||
int free = p.Idle, workers = p.Workers, recruits = p.InTraining, soldiers = p.Soldiers;
|
||||
Assert.AreEqual(p.Total, free + workers + recruits + soldiers, "свободные + рабочие + новобранцы + воины = все люди: " + when);
|
||||
Assert.AreEqual(p.Civilians, free + workers, "жители = свободные + рабочие: " + when);
|
||||
Assert.GreaterOrEqual(free, 0, when);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Groups_NeverOverlap_SumEqualsTotal()
|
||||
{
|
||||
var p = StartCity();
|
||||
AssertGroupsAddUp(p, "старт");
|
||||
|
||||
p.SetWorkers(WorkJob.Farmer, 100);
|
||||
AssertGroupsAddUp(p, "поставили 100 рабочих");
|
||||
|
||||
Assert.IsTrue(p.TryRecruit(150)); // берутся только свободные: 170 - резерв 10 = 160 доступно
|
||||
Assert.AreEqual(100, p.Workers, "рабочих в новобранцы не взяли");
|
||||
AssertGroupsAddUp(p, "наняли 150");
|
||||
|
||||
p.RecruitsTrained(150);
|
||||
army.AddTroops(0, 150);
|
||||
AssertGroupsAddUp(p, "новобранцы стали воинами");
|
||||
Assert.AreEqual(300, p.Total, "людей не прибавилось и не убавилось");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Workers_CannotExceedFreeCivilians()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.SetWorkers(WorkJob.Farmer, 100);
|
||||
p.TryRecruit(150); // жителей осталось 120: 100 рабочих + 20 свободных
|
||||
|
||||
Assert.AreEqual(20, p.SetWorkers(WorkJob.Woodcutter, 50), "поставить можно только 20 свободных -- не воинов и не новобранцев");
|
||||
Assert.AreEqual(0, p.FreeWorkers);
|
||||
Assert.AreEqual(0, p.SetWorkers(WorkJob.Stonecutter, 10), "свободных нет -- никого");
|
||||
AssertGroupsAddUp(p, "все жители на работе");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Restore_WorkersNeverMoreThanCivilians()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.SetWorkers(WorkJob.Farmer, 120);
|
||||
string save = p.Capture().Replace("civilians=270", "civilians=50"); // испорченное сохранение
|
||||
var copy = new PopulationService(new PopulationRules());
|
||||
copy.Restore(save);
|
||||
Assert.AreEqual(50, copy.Workers, "рабочих не больше, чем жителей");
|
||||
Assert.AreEqual(0, copy.Idle);
|
||||
}
|
||||
|
||||
// ---------------- Интеграция: новобранцы переходят из Населения в Армию (настоящий ArmyService) ----------------
|
||||
|
||||
[Test]
|
||||
public void Training_RecruitsMoveToArmy_NoOneLostOrDuplicated()
|
||||
{
|
||||
var p = StartCity();
|
||||
int infantryBefore = army.GetTroops(0);
|
||||
Assert.IsTrue(p.TryRecruit(20));
|
||||
AssertGroupsAddUp(p, "новобранцы в Казармах");
|
||||
|
||||
Assert.IsTrue(p.CompleteTraining(0, 20));
|
||||
Assert.AreEqual(0, p.InTraining);
|
||||
Assert.AreEqual(infantryBefore + 20, army.GetTroops(0), "в Армии +20 пехоты");
|
||||
Assert.AreEqual(300, p.Total, "людей столько же");
|
||||
AssertGroupsAddUp(p, "обучение закончено");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Training_ArmyRejects_RecruitsStayInBarracks()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.TryRecruit(20);
|
||||
int soldiersBefore = p.Soldiers;
|
||||
|
||||
Assert.IsFalse(p.CompleteTraining(99, 20), "такого типа войск нет -- Армия не приняла");
|
||||
Assert.AreEqual(20, p.InTraining, "новобранцы не пропали");
|
||||
Assert.AreEqual(soldiersBefore, p.Soldiers, "и воинов из ничего не появилось");
|
||||
Assert.AreEqual(300, p.Total);
|
||||
AssertGroupsAddUp(p, "Армия не приняла");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Training_RepeatedCompletion_DoesNotDuplicate()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.TryRecruit(20);
|
||||
Assert.IsTrue(p.CompleteTraining(0, 20));
|
||||
Assert.IsFalse(p.CompleteTraining(0, 20), "второй раз новобранцев уже нет");
|
||||
Assert.AreEqual(50, p.Soldiers, "30 стартовых + 20, а не 70");
|
||||
Assert.AreEqual(300, p.Total);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Training_NoArmyModule_NothingChanges()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.TryRecruit(20);
|
||||
GameServices.Unregister<IArmy>(army);
|
||||
Assert.IsFalse(p.CompleteTraining(0, 20));
|
||||
Assert.AreEqual(20, p.InTraining, "без Армии передавать некуда -- новобранцы ждут");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Training_PartOfRecruits_OthersKeepTraining()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.TryRecruit(20);
|
||||
Assert.IsTrue(p.CompleteTraining(1, 8)); // 8 лучников готовы
|
||||
Assert.AreEqual(12, p.InTraining);
|
||||
Assert.AreEqual(8, army.GetTroops(1));
|
||||
Assert.AreEqual(300, p.Total);
|
||||
AssertGroupsAddUp(p, "часть обучена");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reserve_DependsOnTownHallLevel_10_20_30()
|
||||
{
|
||||
var p = StartCity();
|
||||
int[] expected = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30, 30 }; // 11 уровень -- как последний
|
||||
for (int level = 1; level <= expected.Length; level++)
|
||||
{
|
||||
settlement.TownHallLevel = level;
|
||||
Assert.AreEqual(expected[level - 1], p.CivilianReserve, "Ратуша " + level);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recruit_StartCity_Reserve10()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.SetWorkers(WorkJob.Farmer, 120);
|
||||
Assert.AreEqual(10, p.CivilianReserve, "Ратуша 1 -- резерв 10");
|
||||
Assert.AreEqual(140, p.Recruitable, "150 свободных - 10");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recruit_TakesOnlyFree_WorkersStayAtWork()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.SetWorkers(WorkJob.Farmer, 120);
|
||||
p.TryRecruit(140);
|
||||
Assert.AreEqual(120, p.Workers, "рабочие остались на работе");
|
||||
Assert.AreEqual(10, p.Idle, "остался резерв");
|
||||
Assert.AreEqual(0, p.Recruitable);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutoFillAll_NewGame_Places120Workers_RestStayFree()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.AutoFillAll(); // без модуля добычи мест не меньше -- ограничивает только старт: 120 рабочих
|
||||
Assert.AreEqual(120, p.Workers);
|
||||
Assert.AreEqual(150, p.Idle);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recruits_EatLikeSoldiers_AndAreSaved()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.TryRecruit(10);
|
||||
Assert.AreEqual(260 * 0.1 + (30 + 10) * 0.2, p.FoodPerHour, 0.001);
|
||||
|
||||
var copy = new PopulationService(new PopulationRules());
|
||||
copy.Restore(p.Capture());
|
||||
Assert.AreEqual(10, copy.InTraining);
|
||||
Assert.AreEqual(260, copy.Civilians);
|
||||
|
||||
copy.ResetState();
|
||||
Assert.AreEqual(0, copy.InTraining);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OldTrainingOrder_WithoutRecruits_DoesNotGoNegative()
|
||||
{
|
||||
var p = StartCity();
|
||||
p.RecruitsTrained(5); // заказ из старого сохранения -- людей тогда не брали
|
||||
Assert.AreEqual(0, p.InTraining);
|
||||
Assert.AreEqual(270, p.Civilians);
|
||||
}
|
||||
|
||||
// ---------------- История голода (вопрос Саши 7): бой, прошедший офлайн, знает голод на свой момент ----------------
|
||||
|
||||
[Test]
|
||||
public void FamineHistory_OfflineBattleSeesHungerAtItsMoment()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 50); // 33 еды в час -- хватит примерно на полтора часа
|
||||
var p = StartCity();
|
||||
|
||||
p.Tick(T0 + 5 * Hour); // игра была закрыта 5 часов
|
||||
Assert.IsFalse(p.WasStarvingAt(T0 + Hour), "через час еда ещё была");
|
||||
Assert.IsTrue(p.WasStarvingAt(T0 + 2 * Hour), "через два часа -- голод");
|
||||
Assert.IsTrue(p.WasStarvingAt(T0 + 5 * Hour), "и сейчас голод");
|
||||
|
||||
economy.Set(ResourceType.Food, 1000); // привезли еду
|
||||
p.Tick(T0 + 8 * Hour);
|
||||
Assert.IsFalse(p.IsStarving);
|
||||
Assert.IsTrue(p.WasStarvingAt(T0 + 3 * Hour), "прошлый голод помнится");
|
||||
Assert.IsFalse(p.WasStarvingAt(T0 + 7 * Hour), "после еды голода нет");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FamineHistory_SurvivesSave_AndIsClearedByReset()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 50);
|
||||
var p = StartCity();
|
||||
p.Tick(T0 + 5 * Hour);
|
||||
economy.Set(ResourceType.Food, 1000);
|
||||
p.Tick(T0 + 8 * Hour);
|
||||
|
||||
var copy = new PopulationService(new PopulationRules());
|
||||
copy.Restore(p.Capture());
|
||||
Assert.IsTrue(copy.WasStarvingAt(T0 + 3 * Hour));
|
||||
Assert.IsFalse(copy.WasStarvingAt(T0 + Hour));
|
||||
|
||||
copy.ResetState();
|
||||
Assert.IsFalse(copy.WasStarvingAt(T0 + 3 * Hour), "новая игра -- истории нет");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FamineHistory_OlderThan48Hours_IsForgotten()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 50);
|
||||
var p = StartCity();
|
||||
p.Tick(T0 + 5 * Hour);
|
||||
economy.Set(ResourceType.Food, 10000);
|
||||
p.Tick(T0 + 8 * Hour);
|
||||
p.Tick(T0 + 60 * Hour);
|
||||
|
||||
Assert.IsFalse(p.WasStarvingAt(T0 + 3 * Hour), "голод 57 часов назад бою уже не нужен");
|
||||
Assert.IsFalse(p.IsStarving);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FoodReturns_StarvationEnds_GrowthResumes()
|
||||
{
|
||||
|
||||
@@ -59,7 +59,7 @@ public class ProductionTests
|
||||
Assert.AreEqual(22.5f, production.RatePerHour(ResourceType.Stone), 0.001f);
|
||||
Assert.AreEqual(15f, production.RatePerHour(ResourceType.Iron), 0.001f);
|
||||
Assert.AreEqual(100, population.Workers);
|
||||
Assert.AreEqual(20, population.FreeWorkers);
|
||||
Assert.AreEqual(170, population.FreeWorkers, "работать может любой из 270 жителей");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -93,9 +93,10 @@ public class ProductionTests
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест
|
||||
Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить");
|
||||
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Farmer, 1),
|
||||
(WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
||||
Assert.AreEqual(90, population.SetWorkers(WorkJob.Farmer, 120), "свободных осталось 90 из 120");
|
||||
var farms = new List<KeyValuePair<WorkJob, int>> { new KeyValuePair<WorkJob, int>(WorkJob.Woodcutter, 1) };
|
||||
for (int i = 0; i < 9; i++) farms.Add(new KeyValuePair<WorkJob, int>(WorkJob.Farmer, 1)); // 270 мест
|
||||
production.SetBuildings(farms);
|
||||
Assert.AreEqual(240, population.SetWorkers(WorkJob.Farmer, 270), "свободных жителей осталось 240 из 270");
|
||||
Assert.AreEqual(0, population.FreeWorkers);
|
||||
}
|
||||
|
||||
@@ -137,18 +138,69 @@ public class ProductionTests
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter));
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter));
|
||||
Assert.AreEqual(30, population.FreeWorkers);
|
||||
Assert.AreEqual(180, population.FreeWorkers);
|
||||
Assert.IsTrue(population.JobsInitialized);
|
||||
}
|
||||
|
||||
[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)));
|
||||
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer));
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
|
||||
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer), "вторая ферма -- ещё 30");
|
||||
Assert.AreEqual(60, population.GetWorkers(WorkJob.Farmer));
|
||||
population.AutoFillAll(); // стартовая расстановка -- один раз
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); // построили вторую ферму
|
||||
population.ClampJobs();
|
||||
Assert.AreEqual(60, production.Slots(WorkJob.Farmer));
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer), "новые места пустые -- сами не заполняются");
|
||||
|
||||
Assert.AreEqual(60, population.SetWorkers(WorkJob.Farmer, 60), "игрок ставит сам");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StartPlacement_HappensOnlyOnce()
|
||||
{
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
|
||||
population.AutoFillAll();
|
||||
Assert.IsTrue(population.JobsInitialized);
|
||||
population.SetWorkers(WorkJob.Farmer, 0); // игрок снял всех
|
||||
|
||||
// Сохранение и загрузка: расстановка уже была, повторно не нужна
|
||||
var copy = new PopulationService(new PopulationRules());
|
||||
copy.Restore(population.Capture());
|
||||
Assert.IsTrue(copy.JobsInitialized, "второй раз CitySync расстановку не вызовет");
|
||||
Assert.AreEqual(0, copy.GetWorkers(WorkJob.Farmer));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -159,7 +211,7 @@ public class ProductionTests
|
||||
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
|
||||
population.ClampJobs();
|
||||
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
|
||||
Assert.AreEqual(90, population.FreeWorkers);
|
||||
Assert.AreEqual(240, population.FreeWorkers);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -27,12 +27,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
public event Action<MarchData> OnBattleResolved;
|
||||
public event Action<MarchData> OnMarchReturned;
|
||||
|
||||
// Время мира. В мультиплеере -- время сервера, а не телефона
|
||||
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
|
||||
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
|
||||
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000;
|
||||
|
||||
private readonly List<WorldEvent> events = new List<WorldEvent>();
|
||||
private WorldSimulation world;
|
||||
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
|
||||
private List<CampState> pendingCamps;
|
||||
|
||||
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
|
||||
public WorldSimulation World
|
||||
@@ -42,8 +44,11 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
if (world == null)
|
||||
{
|
||||
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>());
|
||||
world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditions() : BattleCondition.None;
|
||||
if (pendingMarches != null) world.LoadMarches(pendingMarches);
|
||||
if (pendingCamps != null) world.LoadCampStates(pendingCamps);
|
||||
pendingMarches = null;
|
||||
pendingCamps = null;
|
||||
}
|
||||
return world;
|
||||
}
|
||||
@@ -88,6 +93,16 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
||||
}
|
||||
|
||||
// Состояние войск игрока сейчас: голод -- из модуля Населения (план от 27.10.2026: проверяется на момент боя).
|
||||
// Бой, прошедший пока игра была закрыта, получает голод на момент входа: истории голода у Населения нет.
|
||||
// Усталость пока не выставляется -- правила её учёта не утверждены.
|
||||
public static BattleCondition PlayerConditions()
|
||||
{
|
||||
return GameServices.TryGet(out IPopulation population) && population.IsStarving
|
||||
? BattleCondition.Famine
|
||||
: BattleCondition.None;
|
||||
}
|
||||
|
||||
// ---------------- Контракт IWorld ----------------
|
||||
|
||||
public int ActiveMarches => World.Marches.Count;
|
||||
@@ -99,17 +114,26 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
error = "Нет данных о войске -- запусти игру из города";
|
||||
return false;
|
||||
}
|
||||
if (army.SoldiersAtHome < soldiers)
|
||||
if (army.AvailableForMarch < soldiers)
|
||||
{
|
||||
error = "Не хватает воинов. Наймите их в Казармах";
|
||||
error = army.GarrisonTotal > 0
|
||||
? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»"
|
||||
: "Не хватает воинов. Наймите их в Казармах";
|
||||
return false;
|
||||
}
|
||||
|
||||
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
|
||||
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();
|
||||
|
||||
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
|
||||
@@ -121,7 +145,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
void HandleBattleResolved(MarchData march)
|
||||
{
|
||||
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}.");
|
||||
OnBattleResolved?.Invoke(march);
|
||||
EventBus.Publish(new MarchBattleResolvedEvent
|
||||
@@ -137,7 +162,17 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
// Солдаты и добыча зачисляются, только когда войско дошло до дома
|
||||
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>();
|
||||
foreach (var reward in march.loot)
|
||||
@@ -156,6 +191,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
Victory = march.victory,
|
||||
Survivors = march.survivors,
|
||||
Sent = march.soldiers,
|
||||
TargetEmpty = march.targetEmpty,
|
||||
TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "",
|
||||
LootText = FormatLoot(march.loot)
|
||||
});
|
||||
@@ -167,25 +203,35 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
class WorldSaveData
|
||||
{
|
||||
public List<MarchData> marches = new List<MarchData>();
|
||||
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
|
||||
}
|
||||
|
||||
public string SaveKey => "world";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
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)
|
||||
{
|
||||
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()
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -7,7 +7,8 @@ namespace Bogatyri.World
|
||||
{
|
||||
PlayerCity, // город игрока
|
||||
NeighborCity, // соседний город -- другой игрок
|
||||
BossCastle // Большой замок с боссом в центре карты
|
||||
BossCastle, // Большой замок с боссом в центре карты
|
||||
Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -31,6 +32,7 @@ namespace Bogatyri.World
|
||||
|
||||
public bool canBeAttacked;
|
||||
public int garrison; // солдат в обороне
|
||||
public int level; // уровень лагеря (1..3), у остальных точек 0
|
||||
public List<RewardEntry> rewards = new List<RewardEntry>();
|
||||
}
|
||||
|
||||
@@ -54,6 +56,22 @@ namespace Bogatyri.World
|
||||
const int MinCitySpacing = 3600; // минимальное расстояние между городами
|
||||
const int MaxPlacementAttempts = 1000;
|
||||
|
||||
// Лагеря разбойников (PvE). Ставятся отдельным генератором после городов -- карта городов не изменилась.
|
||||
public const int CampCount = 12;
|
||||
public const int CampLevels = 3;
|
||||
const int CampSpacing = 2600; // лагерь может стоять ближе к городу, чем город к городу
|
||||
const int CampSeedSalt = 0x5A17C0DE;
|
||||
|
||||
// Требует балансировки. Небольшие числа -- по концепции экономики.
|
||||
static readonly string[] CampNames = { "Стан разбойников", "Логово лиходеев", "Становище Соловья-разбойника" };
|
||||
static readonly int[] CampGarrison = { 6, 14, 26 }; // + 0..3 случайно
|
||||
static readonly int[][] CampRewards = // еда, дерево, золото, металл
|
||||
{
|
||||
new[] { 15, 15, 0, 0 },
|
||||
new[] { 25, 25, 8, 0 },
|
||||
new[] { 40, 40, 15, 8 }
|
||||
};
|
||||
|
||||
// Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков.
|
||||
static readonly string[] NeighborNames =
|
||||
{
|
||||
@@ -128,11 +146,45 @@ namespace Bogatyri.World
|
||||
nodes.Add(city);
|
||||
}
|
||||
|
||||
AddCamps(nodes, new DeterministicRandom(seed ^ CampSeedSalt), CampCount);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Случайная точка, не слишком близко к замку и к другим городам
|
||||
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y)
|
||||
static void AddCamps(List<MapNode> nodes, DeterministicRandom rng, int count)
|
||||
{
|
||||
string[] rewardTypes = { "Food", "Wood", "Gold", "Iron" };
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!TryFindFreeSpot(rng, nodes, out int x, out int y, CampSpacing)) break;
|
||||
|
||||
int level = i % CampLevels + 1; // поровну лагерей каждого уровня
|
||||
var camp = new MapNode
|
||||
{
|
||||
id = $"camp_{i + 1}",
|
||||
displayName = CampNames[level - 1],
|
||||
type = MapNodeType.Camp,
|
||||
x = x,
|
||||
y = y,
|
||||
canBeAttacked = true,
|
||||
level = level,
|
||||
garrison = CampGarrison[level - 1] + rng.Range(0, 4)
|
||||
};
|
||||
int[] amounts = CampRewards[level - 1];
|
||||
for (int r = 0; r < amounts.Length; r++)
|
||||
if (amounts[r] > 0) camp.rewards.Add(new RewardEntry { resource = rewardTypes[r], amount = amounts[r] });
|
||||
|
||||
nodes.Add(camp);
|
||||
}
|
||||
}
|
||||
|
||||
// Защитники за стенами: замок и города. Лагерь -- бой в поле.
|
||||
public static bool IsFortified(MapNode node)
|
||||
{
|
||||
return node != null && (node.type == MapNodeType.BossCastle || node.type == MapNodeType.NeighborCity);
|
||||
}
|
||||
|
||||
// Случайная точка, не слишком близко к замку и к другим точкам
|
||||
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y, int spacing = MinCitySpacing)
|
||||
{
|
||||
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
|
||||
{
|
||||
@@ -142,7 +194,7 @@ namespace Bogatyri.World
|
||||
bool free = true;
|
||||
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)
|
||||
{
|
||||
free = false;
|
||||
@@ -157,6 +209,47 @@ namespace Bogatyri.World
|
||||
return false;
|
||||
}
|
||||
|
||||
// Состав гарнизона по типам войск (TroopType). Вычисляется из id цели, а не из генератора карты:
|
||||
// так карта (координаты, гарнизоны) осталась прежней, а состав всегда одинаков у клиента и сервера.
|
||||
public static int[] GarrisonUnits(MapNode node)
|
||||
{
|
||||
var units = new int[TroopType.Count];
|
||||
if (node == null || node.garrison <= 0) return units;
|
||||
|
||||
int[] percent;
|
||||
if (node.type == MapNodeType.BossCastle)
|
||||
{
|
||||
percent = new[] { 40, 30, 20, 10 }; // Змей Горыныч держит всех понемногу
|
||||
}
|
||||
else
|
||||
{
|
||||
uint h = StableHash(node.id);
|
||||
int infantry = 30 + (int)(h % 31); // 30..60%
|
||||
int archers = 10 + (int)(h / 31 % 26); // 10..35%
|
||||
int cavalry = 5 + (int)(h / 806 % 21); // 5..25%
|
||||
int siege = Math.Max(0, 100 - infantry - archers - cavalry);
|
||||
if (node.type == MapNodeType.Camp) { infantry += siege; siege = 0; } // у разбойников нет осадных машин
|
||||
percent = new[] { infantry, archers, cavalry, siege };
|
||||
}
|
||||
|
||||
int given = 0;
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
units[i] = node.garrison * percent[i] / 100;
|
||||
given += units[i];
|
||||
}
|
||||
units[TroopType.Infantry] += node.garrison - given; // остаток округления -- пехоте
|
||||
return units;
|
||||
}
|
||||
|
||||
// FNV-1a: одинаков на любой платформе (string.GetHashCode в .NET меняется между запусками)
|
||||
static uint StableHash(string s)
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
foreach (char c in s ?? "") hash = (hash ^ c) * 16777619;
|
||||
return hash;
|
||||
}
|
||||
|
||||
public static MapNode Find(List<MapNode> nodes, string id)
|
||||
{
|
||||
foreach (var n in nodes)
|
||||
|
||||
@@ -23,8 +23,19 @@ namespace Bogatyri.World
|
||||
public int soldiers; // сколько ушло
|
||||
public int survivors; // сколько возвращается (известно после боя)
|
||||
public bool victory;
|
||||
public int defenders; // гарнизон цели в момент боя
|
||||
public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются
|
||||
public int defenders; // гарнизон цели в момент боя
|
||||
public int defenderLosses; // сколько потерял защитник
|
||||
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
|
||||
public int[] survivorUnits; // выжившие по типам (известно после боя)
|
||||
|
||||
public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется
|
||||
public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента)
|
||||
public long defenderPower;
|
||||
public int attackerLuck;
|
||||
public int defenderLuck;
|
||||
public int attackerConditions; // BattleCondition нападающего в бою (голод, усталость)
|
||||
public int attackerConditionPercent; // сколько силы оставили состояния: 100 -- без штрафов
|
||||
public List<RewardEntry> loot = new List<RewardEntry>();
|
||||
|
||||
public long departMs;
|
||||
@@ -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
|
||||
{
|
||||
BattleResolved, // войско дошло, бой прошёл
|
||||
@@ -64,6 +89,8 @@ namespace Bogatyri.World
|
||||
private readonly List<MapNode> nodes;
|
||||
private readonly IBattleSystem battleSystem;
|
||||
private readonly List<MarchData> marches = new List<MarchData>();
|
||||
private readonly Dictionary<string, long> campRespawn = new Dictionary<string, long>(); // id лагеря -> когда появится
|
||||
private readonly List<MarchData> arrivals = new List<MarchData>();
|
||||
private int nextMarchId = 1;
|
||||
|
||||
public IReadOnlyList<MapNode> Nodes => nodes;
|
||||
@@ -76,6 +103,11 @@ namespace Bogatyri.World
|
||||
this.battleSystem = battleSystem;
|
||||
}
|
||||
|
||||
// Состояние войска нападающего на момент боя (голод в его поселении, усталость).
|
||||
// Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения,
|
||||
// сервер -- по своим данным). Не задано -- без штрафов.
|
||||
public Func<MarchData, BattleCondition> AttackerConditions { get; set; }
|
||||
|
||||
public MapNode GetNode(string 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)
|
||||
{
|
||||
MapNode from = GetNode(fromId);
|
||||
@@ -104,6 +166,11 @@ namespace Bogatyri.World
|
||||
|
||||
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
|
||||
if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; }
|
||||
if (target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs))
|
||||
{
|
||||
error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин";
|
||||
return null;
|
||||
}
|
||||
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
|
||||
|
||||
var march = new MarchData
|
||||
@@ -118,24 +185,42 @@ namespace Bogatyri.World
|
||||
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
|
||||
state = MarchState.Outbound
|
||||
};
|
||||
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
|
||||
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
|
||||
march.battleSeed = BattleSeed(march.id, nowMs, targetId);
|
||||
marches.Add(march);
|
||||
|
||||
error = null;
|
||||
return march;
|
||||
}
|
||||
|
||||
// Откат только что созданного похода: войска для него не удалось зарезервировать.
|
||||
// Отменить можно только поход, который ещё не дошёл до цели (боя не было).
|
||||
public bool CancelMarch(int marchId)
|
||||
{
|
||||
MarchData m = marches.Find(x => x.id == marchId);
|
||||
if (m == null || m.state != MarchState.Outbound) return false;
|
||||
marches.Remove(m);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
|
||||
// может и дойти, и вернуться -- события придут в правильном порядке.
|
||||
public void Advance(long nowMs, List<WorldEvent> events)
|
||||
{
|
||||
// Бои -- в порядке прихода войск: кто первым дошёл до лагеря, тот его и разбил
|
||||
arrivals.Clear();
|
||||
foreach (var m in marches)
|
||||
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs) arrivals.Add(m);
|
||||
arrivals.Sort((a, b) => a.arriveMs != b.arriveMs ? a.arriveMs.CompareTo(b.arriveMs) : a.id.CompareTo(b.id));
|
||||
foreach (var m in arrivals)
|
||||
{
|
||||
ResolveBattle(m);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
m.state = MarchState.Finished;
|
||||
@@ -144,30 +229,89 @@ namespace Bogatyri.World
|
||||
}
|
||||
|
||||
marches.RemoveAll(m => m.state == MarchState.Finished);
|
||||
|
||||
// Лагеря, которые уже появились снова, больше не нужно помнить
|
||||
expiredCamps.Clear();
|
||||
foreach (var c in campRespawn) if (nowMs >= c.Value) expiredCamps.Add(c.Key);
|
||||
foreach (var id in expiredCamps) campRespawn.Remove(id);
|
||||
}
|
||||
|
||||
private readonly List<string> expiredCamps = new List<string>();
|
||||
|
||||
void ResolveBattle(MarchData m)
|
||||
{
|
||||
MapNode from = GetNode(m.fromNodeId);
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
|
||||
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.survivors = outcome.Survivors;
|
||||
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
|
||||
m.defenders = target.garrison;
|
||||
m.defenderLosses = outcome.DefenderLosses;
|
||||
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
|
||||
// их потери будет списывать сервер из армии того игрока.
|
||||
m.loot = new List<RewardEntry>();
|
||||
if (outcome.Victory)
|
||||
{
|
||||
foreach (var r in target.rewards)
|
||||
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
|
||||
}
|
||||
|
||||
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
|
||||
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
|
||||
m.state = MarchState.Returning;
|
||||
// Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя
|
||||
if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Номер боя из данных похода (FNV-1a) -- одинаков на любой платформе
|
||||
public static long BattleSeed(int marchId, long departMs, string targetId)
|
||||
{
|
||||
ulong h = 14695981039346656037UL;
|
||||
void Mix(ulong v) { for (int i = 0; i < 8; i++) { h = (h ^ (v & 0xFF)) * 1099511628211UL; v >>= 8; } }
|
||||
Mix((ulong)marchId);
|
||||
Mix((ulong)departMs);
|
||||
foreach (char c in targetId ?? "") Mix(c);
|
||||
return (long)h;
|
||||
}
|
||||
|
||||
// Состав похода известен и сходится с числом воинов (у старых походов его нет)
|
||||
static bool HasUnits(MarchData m)
|
||||
{
|
||||
if (m.units == null || m.units.Length != TroopType.Count) return false;
|
||||
int n = 0;
|
||||
foreach (var u in m.units) n += u;
|
||||
return n == m.soldiers && n > 0;
|
||||
}
|
||||
|
||||
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
||||
|
||||
@@ -32,7 +32,8 @@ public static class MarchReturnPopup
|
||||
|
||||
MarchReturnedEvent e = queue.Dequeue();
|
||||
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);
|
||||
|
||||
string text = $"{target}: {result}\n" +
|
||||
|
||||
@@ -2,13 +2,19 @@ using UnityEngine;
|
||||
|
||||
// Подгоняет RectTransform под безопасную зону экрана (Screen.safeArea): вырез камеры, скруглённые углы,
|
||||
// полоска "домой" на iPhone. Следит за поворотом и сменой разрешения (и в Device Simulator).
|
||||
// Вешать на растянутый на весь Canvas объект; всё, что внутри, не залезет под вырез.
|
||||
// Вешать на растянутый объект; всё, что внутри, не залезет под вырез.
|
||||
// - fitInsideParent = false: объект растянут на весь Canvas (HUD);
|
||||
// - fitInsideParent = true: родитель не на весь экран (окно между верхней и нижней панелью) --
|
||||
// зона пересчитывается в координаты родителя, отступ появляется только там, где родитель заходит под вырез.
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
public class SafeArea : MonoBehaviour
|
||||
{
|
||||
public bool fitInsideParent;
|
||||
|
||||
private RectTransform rect;
|
||||
private Rect appliedArea;
|
||||
private Vector2Int appliedScreen;
|
||||
private Rect appliedParent;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
@@ -18,7 +24,8 @@ public class SafeArea : MonoBehaviour
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -29,10 +36,41 @@ public class SafeArea : MonoBehaviour
|
||||
appliedScreen = new Vector2Int(Screen.width, Screen.height);
|
||||
if (Screen.width <= 0 || Screen.height <= 0) return;
|
||||
|
||||
// Якоря в долях экрана: 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);
|
||||
if (!fitInsideParent)
|
||||
{
|
||||
// Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком
|
||||
rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height);
|
||||
rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height);
|
||||
}
|
||||
else
|
||||
{
|
||||
Rect parent = ParentScreenRect();
|
||||
appliedParent = parent;
|
||||
if (parent.width <= 0 || parent.height <= 0) return;
|
||||
|
||||
// Пересечение родителя с безопасной зоной -- в долях родителя
|
||||
float xMin = Mathf.Max(parent.xMin, area.xMin), xMax = Mathf.Min(parent.xMax, area.xMax);
|
||||
float yMin = Mathf.Max(parent.yMin, area.yMin), yMax = Mathf.Min(parent.yMax, area.yMax);
|
||||
if (xMax <= xMin || yMax <= yMin) return;
|
||||
rect.anchorMin = new Vector2((xMin - parent.xMin) / parent.width, (yMin - parent.yMin) / parent.height);
|
||||
rect.anchorMax = new Vector2((xMax - parent.xMin) / parent.width, (yMax - parent.yMin) / parent.height);
|
||||
}
|
||||
rect.offsetMin = Vector2.zero;
|
||||
rect.offsetMax = Vector2.zero;
|
||||
}
|
||||
|
||||
// Прямоугольник родителя в пикселях экрана
|
||||
Rect ParentScreenRect()
|
||||
{
|
||||
var parent = transform.parent as RectTransform;
|
||||
if (parent == null) return new Rect(0, 0, Screen.width, Screen.height);
|
||||
|
||||
var corners = new Vector3[4];
|
||||
parent.GetWorldCorners(corners);
|
||||
Canvas canvas = parent.GetComponentInParent<Canvas>();
|
||||
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null;
|
||||
Vector2 min = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
|
||||
Vector2 max = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
|
||||
return Rect.MinMaxRect(min.x, min.y, max.x, max.y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,15 +44,18 @@ public class WorldMapController : MonoBehaviour
|
||||
public TMP_Text popupBodyText;
|
||||
public Button popupOkButton;
|
||||
|
||||
const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать";
|
||||
const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать";
|
||||
const float CastleSize = 2.2f;
|
||||
const float CitySize = 1.1f;
|
||||
const float CampSize = 0.8f;
|
||||
const float StartZoom = 8f;
|
||||
|
||||
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
|
||||
static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f);
|
||||
static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f);
|
||||
static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f);
|
||||
static readonly Color CampColor = new Color(0.45f, 0.2f, 0.45f);
|
||||
static readonly Color CampClearedColor = new Color(0.35f, 0.35f, 0.35f, 0.6f);
|
||||
|
||||
private Transform mapRoot;
|
||||
private readonly Dictionary<int, MarchView> marchViews = new Dictionary<int, MarchView>();
|
||||
@@ -60,6 +63,14 @@ public class WorldMapController : MonoBehaviour
|
||||
private readonly List<int> staleMarchIds = new List<int>();
|
||||
private MapNode selectedTarget;
|
||||
|
||||
// Лагеря меняют вид: разбит -- серый, с таймером до появления
|
||||
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
|
||||
private float nextCampRefresh;
|
||||
|
||||
// Окно атаки: постоянная часть текста и состояние войска, с которым оно показано
|
||||
private string attackInfoBase;
|
||||
private BattleCondition shownConditions;
|
||||
|
||||
WorldSimulation World => MarchManager.Instance.World;
|
||||
|
||||
void Start()
|
||||
@@ -107,8 +118,13 @@ public class WorldMapController : MonoBehaviour
|
||||
{
|
||||
HandleMapTap();
|
||||
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)
|
||||
@@ -149,6 +165,13 @@ public class WorldMapController : MonoBehaviour
|
||||
CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0);
|
||||
CreateLabel($"{node.displayName}\nГарнизон: {node.garrison}", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f);
|
||||
break;
|
||||
|
||||
case MapNodeType.Camp:
|
||||
// Круг, а не квадрат: лагерь не путается с городом
|
||||
var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0);
|
||||
var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f);
|
||||
campViews.Add((node, sprite, label));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +210,7 @@ public class WorldMapController : MonoBehaviour
|
||||
return sr;
|
||||
}
|
||||
|
||||
void CreateLabel(string text, Vector3 position, float fontSize)
|
||||
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
|
||||
{
|
||||
var go = new GameObject("Label");
|
||||
go.transform.SetParent(mapRoot, false);
|
||||
@@ -198,6 +221,29 @@ public class WorldMapController : MonoBehaviour
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
|
||||
tmp.sortingOrder = 10;
|
||||
return tmp;
|
||||
}
|
||||
|
||||
void RefreshCamps()
|
||||
{
|
||||
if (Time.unscaledTime < nextCampRefresh) return;
|
||||
nextCampRefresh = Time.unscaledTime + 1f;
|
||||
|
||||
long now = MarchManager.NowMs;
|
||||
foreach (var (node, sprite, label) in campViews)
|
||||
{
|
||||
long left = World.CampRespawnInMs(node.id, now);
|
||||
sprite.color = left > 0 ? CampClearedColor : CampColor;
|
||||
label.text = left > 0
|
||||
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
|
||||
: $"{node.displayName}, ур. {node.level}\nГарнизон: {node.garrison}";
|
||||
}
|
||||
}
|
||||
|
||||
static string FormatMinutes(long ms)
|
||||
{
|
||||
long sec = (ms + 999) / 1000;
|
||||
return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек";
|
||||
}
|
||||
|
||||
public static Vector3 ToWorld(MapNode node)
|
||||
@@ -217,6 +263,8 @@ public class WorldMapController : MonoBehaviour
|
||||
if (node == null) return;
|
||||
|
||||
if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него";
|
||||
else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs))
|
||||
hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}";
|
||||
else if (node.canBeAttacked) OpenAttackPanel(node);
|
||||
}
|
||||
|
||||
@@ -225,7 +273,8 @@ public class WorldMapController : MonoBehaviour
|
||||
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);
|
||||
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);
|
||||
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
|
||||
|
||||
attackTitleText.text = target.type == MapNodeType.NeighborCity
|
||||
? $"Город игрока {target.displayName}"
|
||||
attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}"
|
||||
: target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}"
|
||||
: target.displayName;
|
||||
attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" +
|
||||
$"Путь в одну сторону: {travelSeconds} сек\n" +
|
||||
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
||||
attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает
|
||||
attackInfoText.fontSizeMin = 18;
|
||||
attackInfoText.fontSizeMax = 32;
|
||||
attackInfoBase = $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})\n" +
|
||||
$"Путь в одну сторону: {travelSeconds} сек" +
|
||||
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
|
||||
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
||||
RefreshAttackConditions();
|
||||
|
||||
soldiersSlider.minValue = available > 0 ? 1 : 0;
|
||||
soldiersSlider.maxValue = available;
|
||||
@@ -259,12 +313,90 @@ public class WorldMapController : MonoBehaviour
|
||||
attackPanel.SetActive(true);
|
||||
}
|
||||
|
||||
// Голод может начаться или кончиться, пока окно атаки открыто: предупреждение и прогноз
|
||||
// пересчитываются, как только состояние войска изменилось (проверка -- в Update)
|
||||
void RefreshAttackConditions()
|
||||
{
|
||||
shownConditions = MarchManager.PlayerConditions();
|
||||
attackInfoText.text = attackInfoBase + ConditionsWarning(shownConditions);
|
||||
}
|
||||
|
||||
void RefreshOpenAttackPanel()
|
||||
{
|
||||
if (!attackPanel.activeSelf || selectedTarget == null) return;
|
||||
if (MarchManager.PlayerConditions() == shownConditions) return;
|
||||
RefreshAttackConditions();
|
||||
UpdateSoldiersToSendText();
|
||||
}
|
||||
|
||||
void UpdateSoldiersToSendText()
|
||||
{
|
||||
int available = GetSoldiersAtHome();
|
||||
// Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась
|
||||
soldiersToSendText.enableAutoSizing = true;
|
||||
soldiersToSendText.fontSizeMin = 18;
|
||||
soldiersToSendText.fontSizeMax = 32;
|
||||
soldiersToSendText.overflowMode = TMPro.TextOverflowModes.Truncate;
|
||||
var size = soldiersToSendText.rectTransform.sizeDelta;
|
||||
if (size.y < 76) soldiersToSendText.rectTransform.sizeDelta = new Vector2(size.x, 76);
|
||||
soldiersToSendText.text = available > 0
|
||||
? $"Отправить: {(int)soldiersSlider.value} из {available}"
|
||||
: "Дома нет солдат";
|
||||
? $"Отправить: {(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()
|
||||
@@ -292,7 +424,7 @@ public class WorldMapController : MonoBehaviour
|
||||
static int GetSoldiersAtHome()
|
||||
{
|
||||
return ResourceManager.Instance != null
|
||||
? GameServices.Get<IArmy>().SoldiersAtHome // [объединение] наши воины всех типов
|
||||
? GameServices.Get<IArmy>().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город
|
||||
: 0;
|
||||
}
|
||||
|
||||
@@ -335,10 +467,21 @@ public class WorldMapController : MonoBehaviour
|
||||
void HandleBattleResolved(MarchData march)
|
||||
{
|
||||
MapNode target = World.GetNode(march.targetNodeId);
|
||||
if (march.targetEmpty)
|
||||
{
|
||||
EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов");
|
||||
return;
|
||||
}
|
||||
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
|
||||
string body = $"{target.displayName}\n" +
|
||||
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
|
||||
$"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" +
|
||||
(march.attackerPower > 0 || march.defenderPower > 0
|
||||
? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " +
|
||||
$"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" +
|
||||
((march.attackerConditions & (int)BattleCondition.Famine) != 0
|
||||
? $", <color=#E06A5A>голод: сила {march.attackerConditionPercent}%</color>" : "") + "\n"
|
||||
: "") +
|
||||
$"Домой идут: {march.survivors}\n";
|
||||
body += march.victory
|
||||
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
|
||||
@@ -370,6 +513,11 @@ public class WorldMapController : MonoBehaviour
|
||||
var (title, body) = popupQueue.Dequeue();
|
||||
popupTitleText.text = title;
|
||||
popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f);
|
||||
// Итог боя длинный (строка силы и удачи, добыча) -- текст ужимается, а не уходит под кнопку OK
|
||||
popupBodyText.enableAutoSizing = true;
|
||||
popupBodyText.fontSizeMin = 16;
|
||||
popupBodyText.fontSizeMax = 30;
|
||||
popupBodyText.overflowMode = TMPro.TextOverflowModes.Truncate;
|
||||
popupBodyText.text = body;
|
||||
popupPanel.SetActive(true);
|
||||
}
|
||||
|
||||
+36
-7
@@ -1,6 +1,6 @@
|
||||
# Армия
|
||||
|
||||
Войска и их характеристики: сколько воинов каждого типа в городе, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
|
||||
Войска и их характеристики: сколько воинов каждого типа в городе, сколько из них в гарнизоне, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
|
||||
|
||||
- **Сборка:** `Bogatyri.Army` (без Unity).
|
||||
- **Папка:** `Assets/Scripts/Modules/Army`.
|
||||
@@ -12,12 +12,17 @@
|
||||
| --- | --- |
|
||||
| `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). |
|
||||
| `GetTroops(type)` | Воинов этого типа в городе. |
|
||||
| `SoldiersAtHome` | Всего воинов в городе. |
|
||||
| `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). |
|
||||
| `TroopsInMarches` | Воинов в походах. |
|
||||
| `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. |
|
||||
| `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. |
|
||||
| `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. |
|
||||
| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). |
|
||||
| `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. |
|
||||
| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. |
|
||||
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. |
|
||||
| `TakeForMarch(marchId, count)` | Забрать свободных воинов (без гарнизона) в поход пропорционально составу и запомнить состав. `false` — ничего не взято: не хватает воинов или поход с таким номером уже есть. |
|
||||
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. |
|
||||
| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. |
|
||||
| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. |
|
||||
|
||||
Получить: `GameServices.Get<IArmy>()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт.
|
||||
|
||||
@@ -25,9 +30,26 @@
|
||||
|
||||
Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `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=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops)
|
||||
march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
```
|
||||
|
||||
Сброс (`ResetState`): войск и походов нет.
|
||||
Старое сохранение без строки `garrison` читается: гарнизона нет.
|
||||
|
||||
Сброс (`ResetState`): войск, гарнизона и походов нет.
|
||||
|
||||
**Перенос из старых сохранений:**
|
||||
- Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`.
|
||||
@@ -62,13 +87,17 @@ march=<id похода>:<пехота>,<лучники>,<кавалерия>,<о
|
||||
- общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**;
|
||||
- гарнизон ест еду и участвует в защите;
|
||||
- при голоде дебаффы действуют и на гарнизон, и на походы.
|
||||
- **Следующий шаг модуля:** гарнизон — третье место для солдата рядом с «дома» и «в походе».
|
||||
- **Сделано 27.10.2026:** гарнизон города (`SetGarrison`, `AvailableForMarch`).
|
||||
- **Дальше по гарнизону:** участие в защите города (когда на него смогут напасть — PvP), еда гарнизона (сейчас все воины едят одинаково, считает модуль Населения), дебаффы при голоде. Выбор состава по типам — если понадобится.
|
||||
- Описания типов войск (`GameData.Troops`: цена, сила, время обучения) пока в слое приложения; цены перейдут в `EconomyConfig`.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 28.10.2026 | Claude | По ревью 27.10.2026 (п. 2.1–2.2): `ReturnFromMarch` и `ReturnUnits` возвращают `bool` и зачисляют только по известному походу и только один раз; `TakeForMarch` отклоняет повторный номер похода; дубликаты походов в сохранении пропускаются. Тесты: неизвестный поход, повторное возвращение, повторный номер. |
|
||||
| 27.10.2026 | Claude | Гарнизон города: `GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`; в поход уходят только свободные; строка `garrison=` в сохранении; поле `Garrison` в `TroopsChangedEvent`; карточка «Гарнизон города» в окне «Войска». Тест «один солдат в одном месте» с гарнизоном. |
|
||||
| 27.10.2026 | Claude | `GetMarchUnits` и `ReturnUnits`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. |
|
||||
| 23.10.2026 | Claude | Войска переехали в модуль: `ArmyService` — единственное хранилище войск (секция `"army"`), `IArmy` расширен (типы, обучение, потери, войска в походах), событие `TroopsChangedEvent`. Перенос из `progress.json` с удалением старых данных только после записи `save.json`. `ArmyBridge` удалён — контракты и перенос сохранений проверены тестами. |
|
||||
| 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). |
|
||||
| 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. |
|
||||
|
||||
+96
-13
@@ -1,28 +1,87 @@
|
||||
# Боевая система
|
||||
|
||||
Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули.
|
||||
Урон, потери, исход боя с учётом типов войск. Модуль только считает: кто победил и сколько погибло каждого типа. Кто идёт в бой и что делать с итогом, решают другие модули.
|
||||
|
||||
- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере).
|
||||
- **Сборка:** `Bogatyri.Battle` (без Unity и без float — тот же код будет считать бои на сервере).
|
||||
- **Папка:** `Assets/Scripts/Modules/Battle`.
|
||||
- **Зависит от:** ядра.
|
||||
|
||||
## Контракт `IBattleSystem`
|
||||
|
||||
`BattleOutcome Resolve(BattleRequest request)`
|
||||
- `BattleOutcome Resolve(BattleRequest request)` — бой.
|
||||
- `BattleForecast Forecast(BattleRequest request)` — прогноз до боя с учётом случайности. Итог конкретного боя не раскрывает.
|
||||
|
||||
| `BattleRequest` | `BattleOutcome` |
|
||||
| `BattleRequest` | Что это |
|
||||
| --- | --- |
|
||||
| `Attackers` — атакующих | `Victory` — победил атакующий |
|
||||
| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` |
|
||||
| `AttackerUnits`, `DefenderUnits` | Состав сторон по типам (`TroopType`: пехота, лучники, кавалерия, осадные). |
|
||||
| `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:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики.
|
||||
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`.
|
||||
- **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения».
|
||||
- **Открыто: прогноз.** «Исход неясен» охватывает и 27%, и 97% побед (таблица выше). Показывать ли игроку шанс победы в процентах — решает команда (посчитать его можно точно).
|
||||
- **Сделано 28.10.2026: голод в бою.** Голод игрока берётся из модуля Населения (`IPopulation.IsStarving`) в момент боя. Пример: 40 пехоты берут замок Змея Горыныча (168 против 155), голодные — нет (84 против 155). Числа — `famineStrengthPercent` и `fatigueStrengthPercent` в `BattleConfig` (версия правил 2).
|
||||
- **Ограничение голода в офлайне:** если бой прошёл, пока игра была закрыта, берётся голод на момент входа в игру — истории голода у модуля Населения нет. Точно — когда бои будет считать сервер или Население будет хранить, когда начался и кончился голод (договориться с экономикой).
|
||||
- **Голод гарнизона** пока не нужен: на город игрока никто не нападает. Правило защитника уже есть (`DefenderConditions`).
|
||||
- **Открыто: порядок бонусов и случайности.** Сейчас случайность применяется последней, после бонусов стороны.
|
||||
- **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный.
|
||||
- **Командный план и ревью от 27.10.2026** (`Bogatyri_Plan_razrabotki.docx`, `Bogatyri_Battle_Army_Review_27-10-2026.docx`): случайность ±5% каждой стороне (воспроизводимая), прогноз риска вместо точного итога, потери от соотношения сил, раненые и погибшие, голод −50%, усталость −20%, числа — в настройки. Делается на этапе 2 плана.
|
||||
- **Открыто: малые отряды и округление.** Потери округляются вниз по каждому типу, поэтому одиночный проигравший не гибнет никогда. Проверено 28.10.2026:
|
||||
|
||||
| Бой | Исход | Потери атакующего | Потери защитника |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 против 1 | ничья, защитнику | 0 | 0 |
|
||||
| 1 против 10 | поражение | 0 | 0 |
|
||||
| 10 против 1 | победа | 0 | 0 |
|
||||
|
||||
Варианты: округлять потери проигравшего вверх; или воспроизводимая случайность для остатка (одиночка гибнет с шансом 50%). Решает команда, тест `Battle_EdgeCases_OutcomeAndArithmetic` правило не фиксирует.
|
||||
|
||||
- **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`.
|
||||
- **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода.
|
||||
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. Для этого есть `AttackerBonusPercent` / `DefenderBonusPercent` (минус), проценты — из `EconomyConfig`. Голод уже считает модуль Населения (`IsStarving`).
|
||||
- **Гарнизоны соседей** пока не уменьшаются после боя: соседи — заглушки до сервера.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 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` перенесён в ядро. Правила не изменились. |
|
||||
|
||||
+11
-2
@@ -31,10 +31,10 @@
|
||||
| `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. |
|
||||
| `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. |
|
||||
| `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. |
|
||||
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. |
|
||||
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. |
|
||||
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
|
||||
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
|
||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. |
|
||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. |
|
||||
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
|
||||
| `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. |
|
||||
| `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`. |
|
||||
| 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. |
|
||||
| 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. |
|
||||
|
||||
@@ -43,13 +43,15 @@
|
||||
## Добыча через рабочих (этап 3)
|
||||
|
||||
- **Ресурс производят люди, здание даёт места и эффективность.** Добыча в час = min(рабочие, места) × норма × эффективность.
|
||||
- **Голод** (регламент 31.10, ТЕСТ): пока город голодает (`IPopulation.IsStarving`), добыча всего, кроме еды, ×0.5 (`EconomyConfig`, `famineNonFoodMultiplier`). Еда добывается без штрафа, штраф снимается сразу, как только город снова сыт.
|
||||
- **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник).
|
||||
- **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено.
|
||||
- **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен.
|
||||
- **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья.
|
||||
- **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6).
|
||||
- **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн.
|
||||
- **Новые места заполняются сами:** построили или улучшили здание — свободные рабочие встают туда. Старое сохранение при первом запуске — рабочие расставляются поровну по всем местам.
|
||||
- **Новые места сами не заполняются** (регламент 31.10): построили или улучшили здание — места пустые, пока игрок не поставит рабочих в окне здания. Автоматически — только стартовая расстановка один раз (новая игра или первая загрузка старого сохранения): до 120 рабочих поровну по местам.
|
||||
- **Голод в офлайне** считается по тем же шагам, что и в игре: Население досчитывает время кусками по 10 минут, и в каждом куске добыча берёт голод на этот момент.
|
||||
- **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час.
|
||||
|
||||
## События
|
||||
@@ -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` «Люди» на ячейках панели. |
|
||||
| 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. |
|
||||
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. |
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
# Население
|
||||
|
||||
Сколько в городе людей, сколько они едят и как прирастают. Этап 2 плана разработки.
|
||||
Люди города: жители, рабочие, прирост, еда, голод, найм в войско и демографические ограничения. Самостоятельный модуль (регламент 28.10 и 31.10.2026). С Поселением (здания, Ратуша) связан только через контракт ядра `ISettlement`. Этап 2 плана разработки.
|
||||
|
||||
- **Ответственный:** Валдемар.
|
||||
- **Сборка:** `Bogatyri.Population` (без Unity).
|
||||
- **Папка:** `Assets/Scripts/Modules/Population`.
|
||||
- **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`.
|
||||
|
||||
## Как устроено
|
||||
|
||||
- **Люди города = жители + воины.** Жителей хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
|
||||
- **Жители:** способные работать (`Workforce` — 120 из 270 в стартовом городе, доля сохраняется при росте) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`).
|
||||
- **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить.
|
||||
- **Люди города = жители + новобранцы в Казармах + воины.** Жителей и новобранцев хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
|
||||
- **Жители:** работать может любой житель — общего ограничения нет (регламент 31.10). Рабочих ограничивают свободные жители и рабочие места в зданиях. Рабочие стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота), остальные свободны. Стартовый город: 120 рабочих из 270 жителей — при первой расстановке (`AutoFillAll`) больше `StartWorkers` (120) не ставится.
|
||||
- **Найм воинов** (решение команды от 19.10.2026). Воины набираются из свободных жителей (не на работе), гражданский резерв не отдаётся (`Recruitable` = свободные − резерв). Пример команды: 150 свободных, резерв 30 — нанять можно не больше 120. Воин стоит `PeoplePerSoldier` жителей (1). Человек уходит из жителей в начале обучения (`TryRecruit`), учится в Казармах (`InTraining`, ест как воин) и после обучения переходит в Армию (`RecruitsTrained`). Погибшие в бою воины уменьшают население — их больше нет в Армии. Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1–2 — 10, 3–5 — 20, 6–10 — 30 (`EconomyConfig`, `civilianReserveByTownHallLevel`).
|
||||
- **Группы людей не пересекаются** (регламент 31.10). Каждый человек ровно в одной группе: свободный житель (`Idle`), рабочий (`Workers`), новобранец (`InTraining`), воин (`Soldiers`, хранит Армия). `Total` — их сумма. На работу ставятся только свободные жители, в войско берутся только свободные жители сверх резерва.
|
||||
- **Рабочих ставит игрок** в окне здания (`SetWorkers`): не больше, чем мест в зданиях (`IProduction.Slots`) и свободных жителей. Автоматически места заполняются только один раз — стартовая расстановка (`AutoFillAll`, до 120 рабочих) в новой игре или при первой загрузке старого сохранения. Новые места после постройки или улучшения остаются пустыми, пока игрок не поставит рабочих. Мест стало меньше — лишних рабочих снимают (`ClampJobs`).
|
||||
- **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час.
|
||||
- **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья.
|
||||
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Что ещё происходит при голоде, команда пока не решила.
|
||||
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Добыча всего, кроме еды, ×0.5 (модуль Экономики, регламент 31.10, ТЕСТ), еда добывается без штрафа. Бой ослабляет голодное войско (модуль Боя, −50%). Штраф снимается сразу, как только город снова сыт. Смерти от голода нет.
|
||||
- **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру.
|
||||
- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены.
|
||||
- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали.
|
||||
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
|
||||
@@ -31,6 +35,12 @@
|
||||
| `HousingCap` | Предел населения. |
|
||||
| `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. |
|
||||
| `IsStarving` | Еды не хватило на последний расчёт. |
|
||||
| `CivilianReserve` | Гражданский резерв: столько свободных жителей в войско не взять. |
|
||||
| `Recruitable` | Сколько воинов можно нанять прямо сейчас: (свободные − резерв) / жителей на воина. |
|
||||
| `InTraining` | Людей, взятых из жителей и ещё обучающихся в Казармах. |
|
||||
| `TryRecruit(n)`, `RecruitsTrained(n)` | Забрать людей в Казармы в начале обучения; обучение закончено — их считает Армия. |
|
||||
| `CompleteTraining(type, n)` | Передать n новобранцев в Армию одним действием: люди уходят из Населения, только если Армия их приняла. Иначе ничего не меняется — никто не теряется и не дублируется. Так заканчивают обучение Казармы (`GameProgress`). |
|
||||
| `WasStarvingAt(unixTime)` | Голодал ли город в этот момент (секунды Unix, время `GameClock`). Позже последнего расчёта -- как сейчас; старше 48 часов -- нет. |
|
||||
| `Tick(unixNow)` | Досчитать население до момента `unixNow`. |
|
||||
|
||||
Получить: `GameServices.Get<IPopulation>()`.
|
||||
@@ -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`). Старым сохранениям без секции населения стартовый набор не выдаётся.
|
||||
|
||||
@@ -57,6 +67,8 @@
|
||||
| Файл | Что делает |
|
||||
| --- | --- |
|
||||
| `App/GameModules.cs` | Создаёт модуль с числами из `EconomyConfig`, подписывает на события, выдаёт стартовый набор, держит ресурс «Люди» на верхней панели равным `Total`. |
|
||||
| `Meta/Core/GameProgress.cs` | Казармы: перед обучением проверяет `Recruitable`, забирает людей (`TryRecruit`), после обучения — `RecruitsTrained`, потом воины в Армию. |
|
||||
| `Meta/Panels/TroopsPanel.cs` | Панель «Войска»: «Обучаются в Казармах», «Можно нанять из жителей (резерв N)»; кнопки найма серые, если жителей не хватает. |
|
||||
| `App/PopulationTicker.cs` | Раз в 2 секунды вызывает `Tick` (модуль без Unity и не знает про `Update`). |
|
||||
| `App/DebugPanel.cs` | Кнопка «DEV»: «+1 час», «+10 часов», ресурсы, «Еда = 0». Только редактор и Development Build. |
|
||||
|
||||
@@ -80,18 +92,30 @@
|
||||
- перемотка идёт через те же правила;
|
||||
- сохранение;
|
||||
- стартовый набор — один раз;
|
||||
- сброс.
|
||||
- сброс;
|
||||
- найм: 150 свободных, резерв 30 (Ратуша 6) → не больше 120; стартовый город — резерв 10; резерв по уровням Ратуши 10/20/30; стартовая расстановка — 120 рабочих; людей столько же, новобранцы не считаются дважды; рабочие остаются на работе; новобранцы едят как воины и сохраняются; старый заказ обучения не уводит в минус;
|
||||
- история голода: бой офлайн видит голод на свой момент, история сохраняется, сбрасывается и забывается через 48 часов.
|
||||
|
||||
## Решения и план
|
||||
|
||||
- **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`.
|
||||
- **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»).
|
||||
- **Этап 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), добыча в общем расчёте. |
|
||||
| 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)
|
||||
┌───────────┬────────────┬───────────┬──────────────┬───────────────┬────────────┬──────────┬──────────┐
|
||||
Экономика Поселение Армия Глобальный мир Боевая система Сохранения Чат Круг
|
||||
ресурсы, здания, войска, карта, урон, потери, игровое мир и объединение
|
||||
производство развитие характеристики перемещения исход состояние Круг игроков
|
||||
▲
|
||||
App/GameModules — точка сборки: регистрирует модули и соединяет их события
|
||||
со старыми системами (GameProgress, Казармы, панели UI)
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ОБЩЕЕ ИГРОВОЕ ЯДРО (Core) │
|
||||
│ контракты · события · реестр · время игры │
|
||||
└─────────────────────────────────────────┘
|
||||
все модули видят только ядро, друг друга — нет
|
||||
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ ЭКОНОМИКА │ │ ПОСЕЛЕНИЕ │
|
||||
│ ресурсы, склады, │ │ здания, Ратуша, │
|
||||
│ лимиты, добыча │ │ участки, стройка │
|
||||
│ рабочими │ │ │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ НАСЕЛЕНИЕ │ │ АРМИЯ │
|
||||
│ люди, рабочие, еда, │ │ воины по типам: дома,│
|
||||
│ голод, прирост, │ │ в гарнизоне, │
|
||||
│ жильё │ │ в походе │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ ГЛОБАЛЬНЫЙ МИР │ │ БОЕВАЯ СИСТЕМА │
|
||||
│ карта, походы, замок,│ │ сила, контры, стены, │
|
||||
│ соседи, лагеря │ │ удача ±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.Economy`, `Bogatyri.Economy.Rules` (без Unity) | `Assets/Scripts/Modules/Economy` | [Economy.md](Economy.md) |
|
||||
| Поселение | `Bogatyri.Settlement` | `Assets/Scripts/Modules/Settlement` | [Settlement.md](Settlement.md) |
|
||||
| Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.md) |
|
||||
| Население | `Bogatyri.Population` (без Unity) | `Assets/Scripts/Modules/Population` | [Population.md](Population.md) |
|
||||
| Армия | `Bogatyri.Army` (без Unity) | `Assets/Scripts/Modules/Army` | [Army.md](Army.md) |
|
||||
| Глобальный мир | `Bogatyri.World`, `Bogatyri.World.Simulation` (без Unity) | `Assets/Scripts/Modules/World` | [World.md](World.md) |
|
||||
| Боевая система | `Bogatyri.Battle` (без Unity) | `Assets/Scripts/Modules/Battle` | [Battle.md](Battle.md) |
|
||||
| Сохранения | `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**.
|
||||
|
||||
Уже есть: Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
|
||||
Уже есть: голод в бою (−50%, с усталостью 40%, у нападающих и защитников, в момент боя), боевой цикл (случайность воспроизводима и в пределах ±5%, прогноз не противоречит бою, итог сходится по типам у обеих сторон, правила из таблицы, тот же поход — тот же бой), безопасная отправка и возвращение (неизвестный поход, повторное возвращение, повторный номер, откат похода), крайние случаи боя (1×1, 1×10, 0×0, предельные бонусы), гарнизон города (свободные и гарнизон, в поход только свободные, потери, сохранение), лагеря разбойников (расстановка, бой в поле, разбит и появляется снова, второе войско находит лагерь пустым, сохранение), бой с типами войск (контры, стены, осадные, потери в пределах состава), состав гарнизонов, Армия (поход не создаёт и не теряет солдат, потери по типам, сохранение, перенос старых данных, сброс), Круг (основание, вступление, выход, полный Круг, сохранение), канал «Круг» в чате только для участников, чат (пустое сообщение, обрезка длинного, событие, предел истории), деление войск без двойного учёта и без потерь, бой (больше солдат — победа, ничья — защитнику), поход с офлайн-прогрессом, шина событий (ошибка подписчика не ломает остальных), реестр модулей.
|
||||
|
||||
Добавить вместе с этапами 1, 2 и 5 (решение команды от 19.10.2026):
|
||||
|
||||
- [x] лимит жилья: 300 жителей при лимите 300 — прирост стоит, предел вырос — прирост идёт (`PopulationTests`; пока предел растёт от Ратуши, Дома ещё нет);
|
||||
- [ ] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120;
|
||||
- [ ] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (основа — тест «один солдат в одном месте» для походов уже есть);
|
||||
- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу;
|
||||
- [x] нельзя призвать ниже гражданского резерва: 150 свободных, резерв 30 → призвать можно не больше 120; резерв по уровню Ратуши 10/20/30 (`PopulationTests`);
|
||||
- [x] назначение гарнизона: 30 солдат, 10 в гарнизон → 20 доступны, всего по-прежнему 30 (`ArmyService_Garrison_OneSoldierInOnePlace`);
|
||||
- [ ] голод и восстановление: еда кончилась → дебаффы, в том числе у защитников; еда вернулась → снятие по правилу. Бой уже проверен (`Battle_Famine_HalvesPower_AndStacksWithFatigue`, `World_FamineAtBattleTime_WeakensMarch`: −50% у нападающих и у защитников, вместе с усталостью 40%), история голода на момент боя — `PopulationTests`; добыча кроме еды при голоде ×0.5 и снятие штрафа сразу после еды — `ProductionTests` (31.10);
|
||||
- [ ] предел синих кристаллов: кристаллы не копятся бесконечно, сверх лимита — по общему правилу.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 31.10.2026 | Claude | Окна зданий: подписи над зданиями убраны, у каждого здания (добывающие, Дом, Склад, Амбар, Ратуша, Кристалл) своё окно по тапу — состояние, добыча и склад, улучшение и его цена. Казармы тоже получили окно и уровни (открывают новые войска вместе с Ратушей; уровни 1/2/3/5 — ТЕСТ). Ратуша улучшается кнопкой в окне, а не тапом. Обновлён Settlement. |
|
||||
| 31.10.2026 | Claude | По регламенту 31.10: откат — Население снова самостоятельный модуль (`Modules/Population`, `Bogatyri.Population`), схема и «Кто отвечает» как у Саши. Работать может любой житель (без «40%»), резерв по уровню Ратуши 10/20/30, голод режет добычу кроме еды вдвое. Обновлены Population, Settlement, Economy, Core. |
|
||||
| 28.10.2026 | Claude | Население стало частью Поселения, как на схеме Димы: `Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом, история голода для боя. Обновлены Settlement, Population, Core. |
|
||||
| 28.10.2026 | Claude | Схема модулей обновлена: десять модулей (добавлено Население), «Не путать» (Поселение и Население, Армия и Боевая система), кто за что отвечает. |
|
||||
| 28.10.2026 | Claude | Голод в бою (−50%) и правило усталости (−20%, вместе 40%). Обновлены Battle, World, Core. |
|
||||
| 28.10.2026 | Claude | Этап 2 плана, начало: таблица баланса боя `BattleConfig`, случайность ±5%, прогноз риска, полный итог боя для обеих сторон. Обновлены Battle, World, Core. |
|
||||
| 28.10.2026 | Claude | Исправления по ревью 27.10.2026 (п. 2.1, 2.2, 2.4): воины не появляются из неизвестных и повторных походов, отправка с откатом, тесты крайних случаев боя. Обновлены Army, World, Battle, Core. |
|
||||
| 27.10.2026 | Claude | Слой приложения: содержимое окон города (`BasePanel`) — в безопасной зоне, левый край больше не уходит под вырез камеры. `SafeArea.fitInsideParent` — для объектов не на весь экран. |
|
||||
| 27.10.2026 | Claude | Гарнизон города в модуле Армии. Обновлены Army, Core, World. |
|
||||
| 27.10.2026 | Claude | Лагеря разбойников (PvE) на карте мира. Обновлены World, Battle, Save, Core. |
|
||||
| 27.10.2026 | Claude | Боевая система с типами войск (контры, стены, осадные, потери по типам), состав гарнизонов на карте, ожидаемый исход в окне атаки. Обновлены Battle, World, Army, Core. |
|
||||
| 27.10.2026 | Claude | Свободная стройка на сетке: призрак здания, красные занятые клетки, разный размер зданий. Обновлён Settlement. |
|
||||
| 27.10.2026 | Claude | Этапы 1 и 3: склады и лимиты, добыча через рабочих, окно здания и рабочих, улучшение зданий, Склад и Амбар. Тесты `ProductionTests`. Обновлены Economy, Population, Core, Save. |
|
||||
| 26.10.2026 | Claude | Модуль Населения ([Population.md](Population.md)), таблица баланса `EconomyConfig` (`Assets/Resources/EconomyConfig.asset`), часы игры `GameClock`, отладочная панель «DEV». Обновлены Core, Economy, Save. |
|
||||
| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе (`UI/MarchReturnPopup.cs`), обновлён World. |
|
||||
|
||||
@@ -34,7 +34,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
| --- | --- |
|
||||
| `economy` | Экономика |
|
||||
| `settlement` | Поселение |
|
||||
| `world` | Глобальный мир |
|
||||
| `world` | Глобальный мир (походы и разбитые лагеря) |
|
||||
| `circle` | Круг (простые строки, не JSON — модуль без Unity) |
|
||||
| `army` | Армия (простые строки: `troops=…`, `march=…`) |
|
||||
| `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) |
|
||||
@@ -71,7 +71,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
- **Экономика** — стартовые ресурсы из Inspector `ResourceManager` (лимиты склада пересчитает `App/CitySync` по зданиям);
|
||||
- **Добыча** — мест нет (построек нет);
|
||||
- **Поселение** — Ратуша 1 уровня, постройки убраны, участки свободны;
|
||||
- **Мир** — все походы отменены;
|
||||
- **Мир** — все походы отменены, все лагеря разбойников на месте;
|
||||
- **Круг** — игрок ни в каком Круге, его Круги распущены;
|
||||
- **Армия** — войск и походов нет;
|
||||
- **Население** — стартовый город (270 жителей). После сброса `App/GameModules` выдаёт стартовое войско и ресурсы из `EconomyConfig`.
|
||||
@@ -106,6 +106,7 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. |
|
||||
| 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). |
|
||||
| 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. |
|
||||
| 23.10.2026 | Claude | Новая секция `army` (модуль Армии). `GameLoadedEvent` публикуется и без файла сохранения. |
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
# Поселение
|
||||
|
||||
Здания и развитие города: Ратуша, участки под застройку, постройка зданий, восстановление города из сохранения.
|
||||
Здания и развитие города: Ратуша, участки, свободная стройка на сетке, постройка зданий, восстановление города из сохранения. Люди города — отдельный модуль [Население](Population.md), связь только через контракты ядра.
|
||||
|
||||
- **Ответственность:** совместная.
|
||||
- **Сборка:** `Bogatyri.Settlement`.
|
||||
- **Папка:** `Assets/Scripts/Modules/Settlement`.
|
||||
- **Зависит от:** ядра. Ресурсы на стройку берёт через `IEconomy`.
|
||||
|
||||
## Стройка на сетке
|
||||
|
||||
Игрок сам выбирает, где строить. Участки (`BuildSlot`) больше не нужны: они и площадки «Застройка» прячутся при запуске, сцену менять не надо.
|
||||
|
||||
- **Сетка (`BuildGrid`).** Клетка — 1 единица мира, сетка прямоугольная. Область стройки — прямоугольник вокруг старых участков и Ратуши с запасом `padding` (3 клетки). Можно задать руками: поставить `BuildGrid` на сцену, `autoArea = false`, `areaMin` / `areaMax`. Объект создаётся сам, если его нет.
|
||||
- **Занятые клетки:** под Ратушей и под каждым построенным зданием. Пересчёт раз в 0.3 с, пока сетка видна, и перед каждой постройкой.
|
||||
- **Размер здания:** `BuildingData.footprint` (ширина × высота в клетках). `0 × 0` — посчитать по `BoxCollider2D` префаба (иначе по картинке), округлив до клеток. Размер здания задаётся там же, в карточке здания.
|
||||
- **Призрак (`BuildGhost`):** полупрозрачная копия картинки здания и пятно клеток под ней — зелёное (свободно) или красное (занято / за границей). Только картинка: скриптов и коллайдеров нет, экономика его не видит.
|
||||
- **Постройка:** `BuildManager.BuildAt(data, cell)` — проверяет клетки и ресурсы, списывает цену, ставит префаб так, чтобы его коллайдер лёг на клетки призрака, публикует `BuildingPlacedEvent`. `CanBuildAt(data, cell)` — та же проверка без постройки.
|
||||
- **Старые сохранения:** здания встают на свои позиции, занятость считается по тому, какие клетки здание накрывает. Ничего переносить не нужно.
|
||||
|
||||
Управление — `Meta/Build/BuildModeController` (слой приложения):
|
||||
|
||||
1. «СТРОЙКА» — сетка на земле, подсказка сверху, список зданий (размер в клетках, цена; закрытые по уровню Ратуши — серые).
|
||||
2. «Выбрать» — призрак встаёт в центр экрана или на ближайшее свободное место.
|
||||
3. Тащить призрак пальцем (за пятно) или тапнуть по земле — призрак прыгает туда. Камера в это время стоит (`UIBlocker.WorldInputCaptured`).
|
||||
4. Нижняя панель: «Поставить» (место свободно и хватает ресурсов), «Другое», «Отмена».
|
||||
5. После постройки режим остаётся; «Здания» сверху — строить дальше, «Выйти» — сетка прячется.
|
||||
|
||||
## Контракт `ISettlement`
|
||||
|
||||
| Свойство | Что значит |
|
||||
@@ -39,10 +59,12 @@
|
||||
| Файл | Что это |
|
||||
| --- | --- |
|
||||
| `SettlementModule.cs` | Точка входа: `ISettlement`, сохранение города, восстановление на сцене. |
|
||||
| `BuildManager.cs` | Режим стройки, список зданий, участки. |
|
||||
| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, цена, нужный уровень Ратуши. |
|
||||
| `BuildManager.cs` | Список зданий, постройка на сетке (`BuildAt`), поиск данных здания по имени. |
|
||||
| `BuildGrid.cs` | Сетка стройки: область, занятые клетки, рисунок сетки. Там же `BuildFootprint` — размер здания в клетках. |
|
||||
| `BuildGhost.cs` | Призрак здания в режиме стройки. |
|
||||
| `BuildingData.cs` | Описание здания (ScriptableObject): префаб, размер на сетке, цена, нужный уровень Ратуши. |
|
||||
| `TownHall.cs` | Ратуша и её улучшение. Раньше файл назывался `NewEmptyCSharpScript.cs`. |
|
||||
| `BuildSlot.cs` | Участок под здание и связь здания с участком. |
|
||||
| `BuildSlot.cs` | Участок под здание (старая схема, участки прячутся) и связь здания с участком. |
|
||||
| `PlacedBuildingId.cs` | Метка построенного здания. Раньше файл назывался `PlaceBuilding.cs`. |
|
||||
|
||||
В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`).
|
||||
@@ -53,11 +75,17 @@
|
||||
- Лимит населения = база Ратуши + сумма Домов + бонусы, значения из `EconomyConfig`.
|
||||
- Уровень поселения задаёт доступные уровни и количество жилья, а также минимальный гражданский резерв.
|
||||
- **Этап 3:** рабочие места в зданиях (`BuildingData`), экран распределения рабочих.
|
||||
- **Стройка на сетке (27.10.2026, по просьбе Володи):** вернулись к свободной стройке — игрок сам выбирает место, сетка показывает границы и красные занятые клетки, у зданий разный размер.
|
||||
- **Этап 4 (утверждён без изменений):** доступность зданий зависит от месторождений позиции.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. |
|
||||
| 31.10.2026 | Claude | Казармы стали уровневым зданием (`ILeveledBuilding`, уровень сохраняется как у остальных). Тап открывает окно Казарм: очередь обучения, какие войска открыты и что нужно для остальных, улучшение, кнопка «Нанять войска». Войско открывается, когда хватает и уровня Ратуши, и уровня Казарм; считаются самые прокачанные Казармы, каждые Казармы по-прежнему дают +1 место в очереди. Уровни Казарм: пехота 1, лучники 2, конница 3, осадные 5 (`EconomyConfig`, `soldier.requiredBarracksLevel`) — **ТЕСТ, согласовать с Димой**; цена улучшения общая, тоже не утверждена. |
|
||||
| 31.10.2026 | Claude | Откат: Население снова самостоятельный модуль (`Modules/Population`), из Поселения убрано. |
|
||||
| 28.10.2026 | Claude | Население стало частью Поселения (`Settlement/Population`, сборка `Bogatyri.Settlement.Population`) — как на схеме Димы. Подробности — [Population.md](Population.md). |
|
||||
| 27.10.2026 | Claude | Свободная стройка на сетке: `BuildGrid`, `BuildGhost`, `BuildingData.footprint`, `BuildManager.BuildAt` / `CanBuildAt` / `FindData`. Участки и площадки «Застройка» прячутся. Новый `BuildModeController` (призрак, «Поставить / Другое / Отмена»), камера не двигается, пока тащат призрак. |
|
||||
| 19.10.2026 | Claude | `ResetState()` для сброса сохранений. Убранные здания помечаются, чтобы запись в том же кадре их не сохранила. |
|
||||
| 19.10.2026 | Claude | Модуль выделен. Стройка и Ратуша платят через `IEconomy`. Сохранение города перенесено из `SaveManager` в `SettlementModule` (секция `"settlement"`). Восстановление защищено от двойного запуска и от освобождения занятого участка. Файлы переименованы под имена классов. |
|
||||
|
||||
+41
-5
@@ -1,6 +1,6 @@
|
||||
# Глобальный мир
|
||||
|
||||
Карта и перемещения: замок с боссом, 20 соседних городов, походы по реальному времени (идут, даже когда игра закрыта).
|
||||
Карта и перемещения: замок с боссом, 20 соседних городов, 12 лагерей разбойников, походы по реальному времени (идут, даже когда игра закрыта).
|
||||
|
||||
- **Сборки:**
|
||||
- `Bogatyri.World.Simulation` — карта и походы, чистый C# без Unity (переедет на сервер);
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
- `MarchStartedEvent` — войско выступило;
|
||||
- `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`.
|
||||
|
||||
@@ -47,9 +47,38 @@
|
||||
| Файл | Что это |
|
||||
| --- | --- |
|
||||
| `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. |
|
||||
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей из seed. |
|
||||
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей и 12 лагерей из seed. |
|
||||
| `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. |
|
||||
|
||||
**Бой с типами войск:**
|
||||
- у похода есть состав по типам (`MarchData.units`, берётся у Армии при выступлении) и выжившие по типам (`survivorUnits`);
|
||||
- у цели — состав гарнизона `WorldMapLayout.GarrisonUnits(node)`. Он вычисляется из id цели, а не из генератора карты, поэтому карта осталась прежней. У Змея Горыныча 40% пехоты, 30% лучников, 20% кавалерии, 10% осадных; у соседей пропорции свои у каждого;
|
||||
- замок и города сражаются за стенами;
|
||||
- выжившие возвращаются в Армию точно по типам (`IArmy.ReturnUnits`). Старые походы без состава — как раньше, по пропорции;
|
||||
- окно атаки показывает состав гарнизона и ожидаемый исход: силу сторон, «скорее победа / поражение» и примерные потери. Расчёт тот же, что сделает модуль Боя.
|
||||
|
||||
**Лагеря разбойников (PvE):**
|
||||
- 12 лагерей, по 4 каждого уровня. Ставятся отдельным генератором после городов (`seed ^ CampSeedSalt`), поэтому замок, твой город и соседи остались на прежних местах. Лагерь может стоять ближе к городу (2,6 клетки), чем город к городу (3,6).
|
||||
|
||||
| Уровень | Название | Гарнизон | Добыча | Нужно пехоты (примерно) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Стан разбойников | 6–9 | 15 еды, 15 дерева | 8–12 |
|
||||
| 2 | Логово лиходеев | 14–17 | 25 еды, 25 дерева, 8 золота | 18–21 |
|
||||
| 3 | Становище Соловья-разбойника | 26–29 | 40 еды, 40 дерева, 15 золота, 8 металла | 34–37 |
|
||||
|
||||
- бой в поле: стен нет (`WorldMapLayout.IsFortified`), у разбойников нет осадных — их доля уходит пехоте;
|
||||
- после победы лагерь **разбит** на 30 минут (`CampRules.RespawnMs`, отсчёт от момента боя): на карте серый, с таймером, напасть нельзя;
|
||||
- если к лагерю шли два войска, бьётся первое пришедшее (бои считаются по времени прихода). Второе находит лагерь пустым: боя нет, домой идут все, добычи нет (`MarchData.targetEmpty`, окно «ЛАГЕРЬ ПУСТ»);
|
||||
- на карте лагерь — фиолетовый круг, подпись «название, ур. N, гарнизон».
|
||||
|
||||
**Отправка целиком или никак** (ревью 27.10.2026, п. 2.2): `MarchManager.TryStartMarch` создаёт поход, затем просит Армию забрать воинов. Если Армия отказала, поход откатывается (`WorldSimulation.CancelMarch`) и игрок видит ошибку. Если при возвращении Армия не знает поход, ни воины, ни добыча не зачисляются (предупреждение в консоли).
|
||||
|
||||
**Номер боя:** при выступлении поход получает `battleSeed` (из номера похода, времени выступления и цели) и хранит его в сохранении. Тот же поход всегда даёт тот же бой. После боя в походе запоминаются силы сторон и выпавшая случайность (`attackerPower`, `defenderPower`, `attackerLuck`, `defenderLuck`). Окно итога боя на карте показывает «Сила 356 : 160, удача −3,1% / +3,2%» (твоя / врага); текст окна ужимается, чтобы не уходить под кнопку OK. Окно атаки показывает прогноз риска и диапазоны силы и потерь.
|
||||
|
||||
**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Если бой прошёл, пока игра была закрыта, берётся голод на момент входа (истории голода нет). Окно атаки предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Если голод начался или кончился, пока окно атаки открыто, предупреждение и прогноз обновляются сами. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`.
|
||||
|
||||
**Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы.
|
||||
|
||||
В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`.
|
||||
|
||||
**В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену).
|
||||
@@ -59,11 +88,18 @@
|
||||
- **Этап 0, решено 19.10.2026:** осталась одна карта мира — эта. Маленькая карта (панель «МИР», `Meta/World`) удалена. Перед удалением проверены ссылки в коде, сценах и префабах. Перенос сохранений: солдаты из её походов (`marches` в `progress.json`) возвращаются в город при загрузке — отправленные, а если отряд уже шёл назад, то выжившие. Добыча таких походов не переносится.
|
||||
- **Этап 4 (утверждён без изменений):** у позиций на карте — месторождения и их качество.
|
||||
- **Этап 6:** временные ресурсные точки и кристальные разломы; войска на точке недоступны дома.
|
||||
- **Лагеря:** числа (гарнизон, добыча, 30 минут до появления) — предложение, требуют балансировки. Позже их можно перенести в `EconomyConfig`. В мультиплеере разбитый лагерь — общий для всех игроков, его состояние будет вести сервер.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. |
|
||||
| 28.10.2026 | Claude | Номер боя у похода (`battleSeed`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. |
|
||||
| 28.10.2026 | Claude | По ревью 27.10.2026: отправка с проверкой и откатом (`CancelMarch`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. |
|
||||
| 27.10.2026 | Claude | В поход уходят только свободные воины: гарнизон остаётся в городе. Верхняя панель карты: «Свободно / Гарнизон / В походе». |
|
||||
| 27.10.2026 | Claude | Лагеря разбойников (PvE): 12 лагерей трёх уровней, бой в поле, после победы лагерь разбит на 30 минут, второе войско находит его пустым. Секция `world` хранит разбитые лагеря, в `MarchReturnedEvent` добавлен `TargetEmpty`. Тесты: расстановка лагерей, разбит и появляется снова, сохранение. |
|
||||
| 27.10.2026 | Claude | Бой с типами войск: состав похода и гарнизона, стены у замка и городов, выжившие по типам, ожидаемый исход в окне атаки. Время походов — по `GameClock` (перемотка в DEV проматывает походы). |
|
||||
| 23.10.2026 | Claude | Окно «ВОЙСКО ВЕРНУЛОСЬ» в городе. В `MarchReturnedEvent` добавлены `Sent`, `TargetName`, `LootText`. |
|
||||
| 19.10.2026 | Claude | Маленькая карта мира (`Meta/World`: WorldSystem, WorldMapPanel, WorldData, WorldLine) удалена, окно `Panel_World` убрано из сцены города. Кнопка «МИР» открывает эту карту. Солдаты из походов старой карты возвращаются в город. |
|
||||
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений походы отменяются. |
|
||||
|
||||
Reference in New Issue
Block a user