Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb3baba3b7 | ||
|
|
b3296c7a55 | ||
|
|
b1bb863807 | ||
|
|
8b6a7869a4 | ||
|
|
f50591d5fe | ||
|
|
d30fd34810 | ||
|
|
1b7da37c7e | ||
|
|
af57a13f0f | ||
|
|
0214bdb268 | ||
|
|
482721a704 | ||
|
|
33db41002e | ||
|
|
24268f3cfb | ||
|
|
3a86eadc0b | ||
|
|
5c4ba98fef | ||
|
|
20691e0511 | ||
|
|
b9ce85cdbb | ||
|
|
ed4e661cce | ||
|
|
54036eafcf | ||
|
|
d5747f845d | ||
|
|
552694f882 | ||
|
|
c1832a2ae7 | ||
|
|
a7416ac99d | ||
|
|
ded11b5a3f | ||
|
|
ae9c141b49 | ||
|
|
e8b17a7efa | ||
|
|
d9c0039bd2 | ||
|
|
8e9dbdf1cf | ||
|
|
6772292650 |
@@ -12,12 +12,40 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
|
||||
m_Name: BattleConfig
|
||||
m_EditorClassIdentifier:
|
||||
version: 2
|
||||
version: 7
|
||||
counterPercent: 25
|
||||
wallsPercent: 20
|
||||
siegeVsWallsPercent: 50
|
||||
siegeInFieldPercent: -25
|
||||
loserLossPercent: 50
|
||||
randomPercent: 5
|
||||
famineStrengthPercent: 50
|
||||
fatigueStrengthPercent: 80
|
||||
lossRatioPermille:
|
||||
- 1000
|
||||
- 3000
|
||||
- 5000
|
||||
- 7000
|
||||
- 10000
|
||||
strongLossPermille:
|
||||
- 450
|
||||
- 160
|
||||
- 110
|
||||
- 74
|
||||
- 20
|
||||
weakLossPermille:
|
||||
- 450
|
||||
- 740
|
||||
- 880
|
||||
- 920
|
||||
- 950
|
||||
weakLossMaxPermille: 990
|
||||
deadRatioPermille:
|
||||
- 200
|
||||
- 500
|
||||
- 1000
|
||||
- 3000
|
||||
deadSharePermille:
|
||||
- 700
|
||||
- 450
|
||||
- 300
|
||||
- 200
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
%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: c25bbe993b314a15a6d1a8aaf0017d55, type: 3}
|
||||
m_Name: HospitalConfig
|
||||
m_EditorClassIdentifier:
|
||||
bedsByLevel: 140000001e00000028000000320000003c000000
|
||||
temporaryBeds: 5
|
||||
requiredTownHallLevel: 1
|
||||
buildWood: 250
|
||||
buildStone: 150
|
||||
healGameHours: 04000000040000000600000008000000
|
||||
healFoodPerSoldier: 00000000000000000000000000000000
|
||||
healGoldPerSoldier: 01000000010000000200000003000000
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d92d3f1bd32b432e9a30ee269f436ca0
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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: c249ad34e2f94460b9f99b49c71760c8, type: 3}
|
||||
m_Name: IntelligenceConfig
|
||||
m_EditorClassIdentifier:
|
||||
settlementRadius: 5
|
||||
armyRadius: 1
|
||||
observationPercent: 0a000000140000001e000000
|
||||
testObservationLevel: 0
|
||||
lastContactMinutes: 10
|
||||
approxPercent: 20
|
||||
identifyPercent: 50
|
||||
sampleStepSeconds: 10
|
||||
hideUnexploredObjects: 0
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4972288700954eda8b97a86b3f7a5492
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,26 @@
|
||||
%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: 0427eef4612041d2b8a359f9bf358779, type: 3}
|
||||
m_Name: MarchConfig
|
||||
m_EditorClassIdentifier:
|
||||
infantry: 8
|
||||
archers: 9
|
||||
cavalry: 12
|
||||
siege: 4
|
||||
typeSpeedBonusPercent: 00000000000000000000000000000000
|
||||
armySpeedBonusPercent: 0
|
||||
baseMarchSlots: 2
|
||||
patrolsEnabled: 1
|
||||
patrolMaxActive: 3
|
||||
patrolIntervalMinutes: 10
|
||||
patrolSoldiersMin: 10
|
||||
patrolSoldiersMax: 40
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 99295aa9eabc4ab6bf8cc687f4d89d96
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,58 @@
|
||||
%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: 6ee3759b377140c0b759a4a386327510, type: 3}
|
||||
m_Name: WorldResourceConfig
|
||||
m_EditorClassIdentifier:
|
||||
woodStock:
|
||||
- 200
|
||||
- 600
|
||||
- 1500
|
||||
stoneStock:
|
||||
- 150
|
||||
- 450
|
||||
- 1000
|
||||
metalStock:
|
||||
- 80
|
||||
- 250
|
||||
- 600
|
||||
goldStock:
|
||||
- 30
|
||||
- 100
|
||||
- 250
|
||||
woodPerGameHour: 2
|
||||
stonePerGameHour: 1.5
|
||||
metalPerGameHour: 1
|
||||
goldPerGameHour: 0.5
|
||||
woodCargo: 12
|
||||
stoneCargo: 10
|
||||
metalCargo: 8
|
||||
goldCargo: 5
|
||||
lifetimeRealHours:
|
||||
- 12
|
||||
- 24
|
||||
- 48
|
||||
woodCount:
|
||||
- 4
|
||||
- 3
|
||||
- 1
|
||||
stoneCount:
|
||||
- 4
|
||||
- 2
|
||||
- 1
|
||||
metalCount:
|
||||
- 2
|
||||
- 2
|
||||
- 1
|
||||
goldCount:
|
||||
- 2
|
||||
- 1
|
||||
- 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b95775cbe52447a6abff99bfeb488500
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -13,7 +13,7 @@ using UnityEngine;
|
||||
public class BattleConfig : ScriptableObject
|
||||
{
|
||||
[Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")]
|
||||
public int version = 2;
|
||||
public int version = 4;
|
||||
|
||||
[Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")]
|
||||
public int counterPercent = 25;
|
||||
@@ -23,11 +23,25 @@ public class BattleConfig : ScriptableObject
|
||||
public int siegeVsWallsPercent = 50;
|
||||
[Tooltip("Осадные в поле и в обороне, % (минус)")]
|
||||
public int siegeInFieldPercent = -25;
|
||||
[Tooltip("Потери проигравшего, % от каждого типа. Не окончательная формула")]
|
||||
public int loserLossPercent = 50;
|
||||
[Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")]
|
||||
public int randomPercent = 5;
|
||||
|
||||
[Header("Кривая потерь (решение №14, регламент 31.10, ТЕСТ)")]
|
||||
[Tooltip("Опорные точки соотношения сил (сильная / слабая) в десятых долях процента: 1000 = 1:1, 3000 = 3:1. Первая -- обязательно 1000, дальше по возрастанию")]
|
||||
public int[] lossRatioPermille = { 1000, 3000, 5000, 7000, 10000 };
|
||||
[Tooltip("Доля выбывших у сильной стороны в каждой точке, десятые доли процента: 450 = 45%")]
|
||||
public int[] strongLossPermille = { 450, 160, 110, 74, 20 };
|
||||
[Tooltip("Доля выбывших у слабой стороны в каждой точке, десятые доли процента: 740 = 74%")]
|
||||
public int[] weakLossPermille = { 450, 740, 880, 920, 950 };
|
||||
[Tooltip("Разгром дальше последней точки: слабая сторона плавно стремится к этому пределу, десятые доли процента. Решения 31.10, п. 5: 990 = 99%")]
|
||||
public int weakLossMaxPermille = 990;
|
||||
|
||||
[Header("Погибшие и раненые (регламент 31.10, решения 31.10, п. 7, ТЕСТ)")]
|
||||
[Tooltip("Соотношение сил стороны (своя / чужая) в десятых долях процента: 200 = 1:5, 1000 = 1:1, 3000 = 3:1. По возрастанию")]
|
||||
public int[] deadRatioPermille = { 200, 500, 1000, 3000 };
|
||||
[Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")]
|
||||
public int[] deadSharePermille = { 700, 450, 300, 200 };
|
||||
|
||||
[Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")]
|
||||
public int famineStrengthPercent = 50;
|
||||
[Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")]
|
||||
@@ -58,7 +72,12 @@ public class BattleConfig : ScriptableObject
|
||||
WallsPercent = wallsPercent,
|
||||
SiegeVsWallsPercent = siegeVsWallsPercent,
|
||||
SiegeInFieldPercent = siegeInFieldPercent,
|
||||
LoserLossPercent = loserLossPercent,
|
||||
LossRatioPermille = lossRatioPermille,
|
||||
StrongLossPermille = strongLossPermille,
|
||||
WeakLossPermille = weakLossPermille,
|
||||
WeakLossMaxPermille = weakLossMaxPermille,
|
||||
DeadRatioPermille = deadRatioPermille,
|
||||
DeadSharePermille = deadSharePermille,
|
||||
RandomPercent = Mathf.Max(0, randomPercent),
|
||||
FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100),
|
||||
FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100)
|
||||
|
||||
@@ -14,6 +14,9 @@ using UnityEngine.UI;
|
||||
// - Кристалл: где добывается, запас / лимит;
|
||||
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
|
||||
// кнопка "Нанять войска" (панель "Войска").
|
||||
// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы.
|
||||
// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне.
|
||||
// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11).
|
||||
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
|
||||
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
|
||||
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
|
||||
@@ -54,18 +57,33 @@ public static class BuildingWindow
|
||||
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
|
||||
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
|
||||
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
|
||||
var hospital = focus != null ? focus.GetComponent<Hospital>() : null;
|
||||
var heroCastle = focus != null ? focus.GetComponent<HeroCastle>() : null;
|
||||
|
||||
bool constructing = focus != null && BuildTimer.IsConstructing(focus.transform);
|
||||
if (focus != null) TimerBlock(card, focus);
|
||||
|
||||
if (focus == null) WorkersList(card);
|
||||
else if (constructing) UIKit.Text(card, "Пока здание строится, оно не работает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
else if (townHall != null) TownHallSection(card, townHall);
|
||||
else if (barracks != null) BarracksSection(card, barracks);
|
||||
else if (hospital != null) HospitalSection(card, hospital);
|
||||
else if (heroCastle != null) HeroCastleSection(card);
|
||||
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);
|
||||
|
||||
if (focus != null) ActionsBlock(card, focus);
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
|
||||
}
|
||||
|
||||
// Стройка закончилась (BuildTimer.Completed): окно этого здания показывает новый уровень
|
||||
internal static void OnBuildCompleted(BuildTimer timer)
|
||||
{
|
||||
if (root != null && timer != null && focus == timer.gameObject) Rebuild();
|
||||
}
|
||||
|
||||
internal static void Tick()
|
||||
{
|
||||
if (root == null) return;
|
||||
@@ -190,6 +208,26 @@ public static class BuildingWindow
|
||||
}
|
||||
else StorageBar(card, economy, ResourceType.Food);
|
||||
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IResourceProtection protection))
|
||||
{
|
||||
Header(card, "Защита от грабежа");
|
||||
var p = EconomyConfig.Load().protection;
|
||||
ResourceType[] kinds = warehouse
|
||||
? new[] { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold }
|
||||
: new[] { ResourceType.Food };
|
||||
Live(card, () =>
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var k in kinds)
|
||||
lines.Add($"{GameData.ResName(k)}: спрятано {protection.Protected(k):0}, можно унести {protection.Lootable(k):0}");
|
||||
return string.Join("\n", lines.ToArray());
|
||||
}, 22, UIKit.Theme.Text);
|
||||
UIKit.Text(card, warehouse
|
||||
? $"Каждый уровень Склада прячет ещё {p.perWarehouseLevel:0} каждого ресурса — в набеге их не унесут."
|
||||
: $"Каждый уровень Амбара прячет ещё {p.perGranaryLevel:0} еды — в набеге её не унесут.",
|
||||
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
}
|
||||
|
||||
UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}",
|
||||
@@ -233,20 +271,47 @@ public static class BuildingWindow
|
||||
string unlocks = UnlocksAt(next);
|
||||
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
|
||||
string more = MoreBuildingsAt(level, next);
|
||||
if (more.Length > 0) UIKit.Text(card, "Больше зданий: " + more, 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (TimerRunning(townHall.gameObject)) return; // улучшение уже идёт -- полоска сверху
|
||||
|
||||
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
long ms = config.ConstructionMs(true, next);
|
||||
TimeText(card, ms);
|
||||
bool canPay = townHall.CanUpgrade();
|
||||
var b = UIKit.Button(card, $"Улучшить до {next} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
if (!townHall.CanUpgrade()) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
townHall.Upgrade();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
|
||||
if (ms > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
if (ms <= 0) BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, 0, 0).Complete();
|
||||
else
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, now, now + ms);
|
||||
UIKit.Toast($"Ратуша улучшается до {next} уровня");
|
||||
}
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
}
|
||||
|
||||
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 2 → 3, Дом 3 → 4"
|
||||
static string MoreBuildingsAt(int level, int next)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
var parts = new List<string>();
|
||||
if (config.buildingLimits != null)
|
||||
foreach (var l in config.buildingLimits)
|
||||
{
|
||||
if (l == null) continue;
|
||||
int a = config.BuildingLimitFor(l.buildingName, level), b = config.BuildingLimitFor(l.buildingName, next);
|
||||
if (b > a) parts.Add($"{l.buildingName} {a} → {b}");
|
||||
}
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
// ---------------- Казармы ----------------
|
||||
|
||||
static void BarracksSection(RectTransform card, Barracks barracks)
|
||||
@@ -261,11 +326,11 @@ public static class BuildingWindow
|
||||
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}";
|
||||
var training = gp.Training;
|
||||
if (training.Count == 0) return "Никто не обучается";
|
||||
var o = training[0];
|
||||
string more = training.Count > 1 ? $" (ещё заказов: {training.Count - 1})" : "";
|
||||
return $"{GameData.Troops[o.Type].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);
|
||||
@@ -298,6 +363,112 @@ public static class BuildingWindow
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// ---------------- Госпиталь (решения 31.10, п. 7) ----------------
|
||||
|
||||
static void HospitalSection(RectTransform card, Hospital hospital)
|
||||
{
|
||||
UIKit.Text(card, $"Госпиталь лечит раненых после боёв. Мест: {Hospital.BedsAt(Hospital.MaxLevel)} " +
|
||||
"(считается самый прокачанный госпиталь). Кто не поместился -- ждёт места.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
if (GameServices.TryGet(out IArmy army))
|
||||
{
|
||||
Header(card, "Раненые");
|
||||
Live(card, () =>
|
||||
{
|
||||
int healingNow = 0, waiting = 0;
|
||||
long readyMs = long.MaxValue;
|
||||
foreach (var h in army.Healing) { healingNow += h.Count; readyMs = Math.Min(readyMs, h.EndMs); }
|
||||
for (int t = 0; t < army.TypeCount; t++) waiting += army.GetWoundedWaiting(t);
|
||||
if (healingNow + waiting == 0) return "Раненых нет";
|
||||
string next = readyMs != long.MaxValue
|
||||
? $", ближайшие вылечатся через {GameData.FormatTime(Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)}"
|
||||
: "";
|
||||
return $"Лечатся {healingNow} из {army.HospitalBeds} мест, ждут места {waiting}{next}";
|
||||
}, 24, UIKit.Theme.Text);
|
||||
Live(card, () =>
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var h in army.Healing)
|
||||
if (h.Type >= 0 && h.Type < GameData.Troops.Count)
|
||||
lines.Add($"{GameData.Troops[h.Type].name} ×{h.Count} — {GameData.FormatTime(Math.Max(0, h.EndMs - GameClock.UnixNowMs) / 1000.0)}");
|
||||
return lines.Count > 0 ? string.Join("\n", lines.ToArray()) : "";
|
||||
}, 22, UIKit.Theme.TextDim);
|
||||
var rules = HospitalConfig.Load().healGameHours;
|
||||
UIKit.Text(card, $"Лечение одного воина: пехота и лучники {rules[0]}, кавалерия {rules[2]}, осадные {rules[3]} игровых ч " +
|
||||
"(игровой час = 5 мин). Лечатся и пока игра закрыта.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
|
||||
UpgradeBlock(card, hospital.level, $"мест {Hospital.BedsAt(hospital.level)} → {Hospital.BedsAt(hospital.level + 1)}",
|
||||
() => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel);
|
||||
}
|
||||
|
||||
// ---------------- Терем богатыря: окно героя ----------------
|
||||
// Числа героя -- те же, что в панели "Герой" (GameProgress / GameData), механика не меняется.
|
||||
|
||||
static void HeroCastleSection(RectTransform card)
|
||||
{
|
||||
var gp = GameProgress.Instance;
|
||||
UIKit.Text(card, "В Тереме живёт богатырь -- верховный командир. Он гуляет по городу; тапните по нему, чтобы поговорить.",
|
||||
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
if (gp == null) return;
|
||||
|
||||
int lvl = gp.Data.heroLevel, maxLvl = gp.HeroMaxLevel;
|
||||
var head = UIKit.Row(card, 140, 16);
|
||||
UIKit.Icon(head, new Color(0.55f, 0.4f, 0.2f), "Б", 130); // место под портрет
|
||||
var info = UIKit.Card(head, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
UIKit.Text(info, $"Уровень {lvl} / {maxLvl}", 30, UIKit.Theme.Gold).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, $"Сила героя: {GameData.HeroPower(lvl)}", 24, UIKit.Theme.Text);
|
||||
UIKit.Text(info, $"Бонус к силе армии: +{Mathf.RoundToInt(gp.ArmyBonus * 100)}%", 24, UIKit.Theme.Text);
|
||||
|
||||
Header(card, "Навыки");
|
||||
for (int i = 0; i < GameData.HeroSkillNames.Length; i++)
|
||||
{
|
||||
bool has = gp.HasHeroSkill(i);
|
||||
UIKit.Text(card, has ? $"<color=#9be59b>✓</color> {GameData.HeroSkillNames[i]}"
|
||||
: $"{GameData.HeroSkillNames[i]} — с {GameData.HeroSkillLevels[i]} ур.",
|
||||
22, has ? UIKit.Theme.Text : UIKit.Theme.TextDim);
|
||||
}
|
||||
|
||||
Header(card, "Улучшение героя");
|
||||
if (lvl >= maxLvl)
|
||||
{
|
||||
UIKit.Text(card, $"Предел уровня. Улучшите Ратушу до {gp.TownHallLevel + 1} ур., чтобы поднять предел до " +
|
||||
$"{GameData.HeroMaxLevel(gp.TownHallLevel + 1)}.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
else
|
||||
{
|
||||
var cost = GameData.HeroUpgradeCost(lvl);
|
||||
Live(card, () =>
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var c in cost)
|
||||
{
|
||||
bool enough = gp.Res(c.type) >= c.amount;
|
||||
parts.Add($"<color={(enough ? "#9be59b" : "#ff7a7a")}>{GameData.ResName(c.type)} {c.amount:N0}</color>");
|
||||
}
|
||||
return "Цена: " + string.Join(", ", parts.ToArray());
|
||||
}, 22, UIKit.Theme.Text);
|
||||
UIKit.Text(card, $"Дальше: сила {GameData.HeroPower(lvl)} → {GameData.HeroPower(lvl + 1)}, " +
|
||||
$"бонус армии +{GameData.HeroArmyBonusPercent(lvl)}% → +{GameData.HeroArmyBonusPercent(lvl + 1)}%",
|
||||
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
var up = UIKit.Button(card, $"Улучшить до {lvl + 1} ур.", UIKit.Theme.Green, () =>
|
||||
{
|
||||
if (!gp.CanUpgradeHero) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
gp.UpgradeHero();
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
live.Add(() => up.interactable = gp.CanUpgradeHero);
|
||||
}
|
||||
|
||||
UIKit.Button(card, "Подробнее о герое", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
Close();
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Hero");
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
|
||||
static int ReserveAt(int townHallLevel)
|
||||
{
|
||||
@@ -320,31 +491,103 @@ public static class BuildingWindow
|
||||
|
||||
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
|
||||
|
||||
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel)
|
||||
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel, int maxLevel = 0)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
if (level >= config.upgrade.maxLevel)
|
||||
if (level >= (maxLevel > 0 ? maxLevel : config.upgrade.maxLevel)) // у госпиталя свой предел: I-V
|
||||
{
|
||||
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
Header(card, $"Улучшение до {level + 1} ур.");
|
||||
UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
if (focus != null && TimerRunning(focus)) return; // улучшение уже идёт -- полоска сверху
|
||||
var cost = config.UpgradeCost(level);
|
||||
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
long ms = config.ConstructionMs(false, level + 1);
|
||||
TimeText(card, ms);
|
||||
var building = focus;
|
||||
|
||||
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
|
||||
var b = UIKit.Button(card, $"Улучшить до {level + 1} ур.", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
|
||||
{
|
||||
bool timed = ms > 0 && building != null;
|
||||
if (timed && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
|
||||
raiseLevel();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {level + 1} уровня");
|
||||
if (timed)
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(building, BuildTimer.Kind.Upgrade, level + 1, now, now + ms);
|
||||
UIKit.Toast($"Улучшение до {level + 1} уровня началось");
|
||||
}
|
||||
else
|
||||
{
|
||||
raiseLevel();
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
UIKit.Toast($"Улучшено до {level + 1} уровня");
|
||||
}
|
||||
Rebuild();
|
||||
}, -1, 80, 26);
|
||||
b.interactable = canPay;
|
||||
}
|
||||
|
||||
// ---------------- Стройка по времени (BuildTimer) ----------------
|
||||
|
||||
static bool TimerRunning(GameObject building)
|
||||
{
|
||||
var t = BuildTimer.Of(building);
|
||||
return t != null && BuildTimer.All.Contains(t);
|
||||
}
|
||||
|
||||
static void TimeText(RectTransform card, long ms)
|
||||
{
|
||||
if (ms <= 0) return;
|
||||
Live(card, () => $"Время: {GameData.FormatTime(ms / 1000.0)} • строителей свободно: {BuildTimer.FreeBuilders} из {BuildTimer.Builders}",
|
||||
22, UIKit.Theme.TextDim);
|
||||
}
|
||||
|
||||
// Полоска стройки или улучшения, сколько осталось, ускорение за кристаллы
|
||||
static void TimerBlock(RectTransform card, GameObject building)
|
||||
{
|
||||
var t = BuildTimer.Of(building);
|
||||
if (t == null || !BuildTimer.All.Contains(t)) return;
|
||||
Header(card, t.IsConstruction ? "Идёт стройка" : $"Идёт улучшение до {t.targetLevel} ур.");
|
||||
var bar = UIKit.Bar(card, t.Progress, UIKit.Theme.Gold, 26);
|
||||
live.Add(() => { if (t != null) UIKit.SetBar(bar, t.Progress); });
|
||||
Live(card, () => t != null ? "Осталось " + GameData.FormatTime(t.SecondsLeft) : "Готово", 24, UIKit.Theme.Text);
|
||||
|
||||
int perGem = Mathf.Max(1, EconomyConfig.Load().construction.secondsPerGem);
|
||||
Func<int> gems = () => t != null ? Mathf.Max(1, Mathf.CeilToInt((float)t.SecondsLeft / perGem)) : 0;
|
||||
var speed = UIKit.Button(card, "", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
if (t == null) return;
|
||||
int price = gems();
|
||||
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(ResourceType.Gems, price))
|
||||
{
|
||||
UIKit.Toast($"Нужно {price} кристаллов");
|
||||
return;
|
||||
}
|
||||
t.Complete(); // окно перестроит OnBuildCompleted
|
||||
}, -1, 80, 26);
|
||||
live.Add(() => { if (t != null) UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true); });
|
||||
UIKit.SetButton(speed, $"Ускорить за {gems()} кристаллов", true);
|
||||
}
|
||||
|
||||
// ---------------- Перенос (решения 04.11: сноса нет) ----------------
|
||||
// Здание можно бесплатно переставить в пределах поселения: уровень, стройка или улучшение сохраняются.
|
||||
// Снести здание и вернуть ресурсы нельзя -- такой кнопки нет.
|
||||
|
||||
static void ActionsBlock(RectTransform card, GameObject building)
|
||||
{
|
||||
var id = building.GetComponent<PlacedBuildingId>();
|
||||
if (id == null || string.IsNullOrEmpty(id.buildingName)) return; // Ратуша и стартовые постройки сцены
|
||||
UIKit.Button(card, "Перенести", UIKit.Theme.Blue, () =>
|
||||
{
|
||||
Close();
|
||||
if (BuildModeController.Instance != null) BuildModeController.Instance.StartMove(building);
|
||||
}, -1, 80, 26);
|
||||
}
|
||||
|
||||
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
|
||||
static string CostText(ResourceAmount[] cost)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,8 @@ using UnityEngine;
|
||||
// Слой приложения: сообщает модулям, какие здания стоят в городе.
|
||||
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
|
||||
// - Дома (здание "Население") -> модуль Населения (предел жилья);
|
||||
// - Склады и Амбары -> лимиты склада в Экономике.
|
||||
// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection).
|
||||
// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают.
|
||||
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
|
||||
//
|
||||
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
|
||||
@@ -67,6 +68,7 @@ public class CitySync : MonoBehaviour
|
||||
foreach (var placed in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре
|
||||
if (BuildTimer.IsConstructing(placed)) continue; // ещё строится -- не работает
|
||||
|
||||
var rc = placed.GetComponent<ResourceCollector>();
|
||||
if (rc != null)
|
||||
@@ -112,6 +114,7 @@ public class CitySync : MonoBehaviour
|
||||
ResourceType[] stored = { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Food, ResourceType.Gems };
|
||||
foreach (var t in stored) rm.SetCapacity(t, config.GetCapacity(t, city.WarehouseLevels, city.GranaryLevels));
|
||||
}
|
||||
ResourceProtection.SetLevels(city.WarehouseLevels, city.GranaryLevels); // защита от грабежа
|
||||
|
||||
var production = GameModules.Production;
|
||||
var population = GameModules.Population;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Стройка и офлайн (решения 04.11): стройка, которая закончилась, пока игра была закрыта, вступает в силу
|
||||
// в тот момент офлайн-расчёта, когда время дошло до её конца. Тогда добыча здания (и его новый уровень)
|
||||
// считается ровно с момента завершения, а не с начала офлайна и не с момента входа в игру.
|
||||
//
|
||||
// Как: модуль Населения на каждом шаге расчёта сообщает, до какого момента дошёл (GameTimeSimulatedEvent).
|
||||
// Здесь завершаются стройки с концом не позже этого момента, и город сразу пересчитывается (CitySync) --
|
||||
// следующий шаг добычи идёт уже с новым зданием. Пока расчёт не дошёл до конца стройки, BuildTimer сам её не
|
||||
// завершает (BuildTimer.MayComplete). Шаг расчёта -- 10 минут, поэтому добыча начинается не раньше конца
|
||||
// стройки и не позже чем через 10 минут после него: лишнего не начисляется никогда.
|
||||
public static class ConstructionClock
|
||||
{
|
||||
// До какого момента (мс) расчёт добычи уже дошёл. 0 -- ещё ни разу не считал (сразу после запуска)
|
||||
public static long SimulatedUntilMs { get; private set; }
|
||||
|
||||
static bool subscribed;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
SimulatedUntilMs = 0;
|
||||
BuildTimer.MayComplete = endMs => endMs <= SimulatedUntilMs;
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(OnSimulated);
|
||||
}
|
||||
|
||||
static void OnSimulated(GameTimeSimulatedEvent e)
|
||||
{
|
||||
long ms = e.UnixNow * 1000;
|
||||
if (ms > SimulatedUntilMs) SimulatedUntilMs = ms; // время расчёта только растёт
|
||||
|
||||
bool any = false;
|
||||
foreach (var timer in BuildTimer.All.ToArray())
|
||||
{
|
||||
if (timer == null || timer.endMs > SimulatedUntilMs) continue;
|
||||
timer.Complete(); // уровень только растёт, второй раз не завершается
|
||||
any = true;
|
||||
}
|
||||
if (any && CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6104b5c531ff479eb569880ebd75d68c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -123,6 +123,48 @@ public class EconomyConfig : ScriptableObject
|
||||
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ConstructionSettings // время стройки и улучшения, строители (01.11, ТЕСТ)
|
||||
{
|
||||
[Tooltip("Сколько строек и улучшений идёт одновременно (строители). ТЕСТ")]
|
||||
public int builders = 2;
|
||||
[Tooltip("Постройка нового здания, секунд. ТЕСТ")]
|
||||
public int buildSeconds = 60;
|
||||
[Tooltip("Улучшение до уровня 2, 3, 4... (секунд): [0] -- до 2 ур., [1] -- до 3 ур. ... ТЕСТ")]
|
||||
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
|
||||
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
|
||||
public float townHallTimeMultiplier = 2f;
|
||||
[Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")]
|
||||
public int secondsPerGem = 30;
|
||||
// Сноса зданий нет (решение 04.11): только бесплатный перенос
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class BuildingLimit
|
||||
{
|
||||
public string buildingName;
|
||||
[Tooltip("Сколько таких зданий можно иметь на Ратуше 1, 2, 3 ... уровня. 0 -- нельзя")]
|
||||
public int[] maxByTownHall;
|
||||
|
||||
public BuildingLimit() { }
|
||||
public BuildingLimit(string name, params int[] max) { buildingName = name; maxByTownHall = max; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ProtectionSettings // защита ресурсов от грабежа (01.11, ТЕСТ)
|
||||
{
|
||||
[Tooltip("Защищено без Складов: дерево, камень, металл, золото. ТЕСТ")]
|
||||
public float baseGoods = 100f;
|
||||
[Tooltip("+ к защите дерева, камня, металла, золота за каждый уровень Склада. ТЕСТ")]
|
||||
public float perWarehouseLevel = 200f;
|
||||
[Tooltip("Защищено без Амбаров: еда. ТЕСТ")]
|
||||
public float baseFood = 100f;
|
||||
[Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")]
|
||||
public float perGranaryLevel = 300f;
|
||||
[Tooltip("Кристаллы: без технологии «Грабёж» нападающий не уносит ничего (решение 04.11). С ней -- не больше этой доли запаса. ТЕСТ, точные ограничения определят отдельно")]
|
||||
[Range(0f, 1f)] public float plunderGemsShare = 0.1f;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StartResources
|
||||
{
|
||||
@@ -156,6 +198,30 @@ public class EconomyConfig : ScriptableObject
|
||||
[Header("Солдат T1 (этап 5)")]
|
||||
public SoldierSettings soldier = new SoldierSettings();
|
||||
|
||||
[Header("Стройка: время, строители, снос (ТЕСТ)")]
|
||||
public ConstructionSettings construction = new ConstructionSettings();
|
||||
|
||||
[Header("Лимит зданий по уровню Ратуши (ТЕСТ). Здания не из списка -- без лимита")]
|
||||
public BuildingLimit[] buildingLimits =
|
||||
{
|
||||
// Ратуша: 1 2 3 4 5 6 7 8 9 10
|
||||
new BuildingLimit("Лес", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
|
||||
new BuildingLimit("Еда", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
|
||||
new BuildingLimit("Камень", 2, 2, 3, 3, 4, 4, 5, 5, 6, 6),
|
||||
new BuildingLimit("Металл", 0, 0, 1, 1, 2, 2, 3, 3, 4, 4),
|
||||
new BuildingLimit("Золото", 0, 1, 1, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Население", 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
|
||||
new BuildingLimit("Склад", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Амбар", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
|
||||
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
|
||||
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
|
||||
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1),
|
||||
};
|
||||
|
||||
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
|
||||
public ProtectionSettings protection = new ProtectionSettings();
|
||||
|
||||
// ---------------- Загрузка ----------------
|
||||
|
||||
static EconomyConfig cached;
|
||||
@@ -189,6 +255,49 @@ public class EconomyConfig : ScriptableObject
|
||||
}
|
||||
}
|
||||
|
||||
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -1 -- без лимита (здания нет в списке)
|
||||
public int BuildingLimitFor(string buildingName, int townHallLevel)
|
||||
{
|
||||
if (buildingLimits == null || string.IsNullOrEmpty(buildingName)) return -1;
|
||||
foreach (var l in buildingLimits)
|
||||
{
|
||||
if (l == null || l.buildingName != buildingName || l.maxByTownHall == null || l.maxByTownHall.Length == 0) continue;
|
||||
return Mathf.Max(0, l.maxByTownHall[Mathf.Clamp(townHallLevel, 1, l.maxByTownHall.Length) - 1]);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Время стройки (targetLevel = 1) или улучшения до targetLevel, мс. Ратуша -- с множителем
|
||||
public long ConstructionMs(bool townHall, int targetLevel)
|
||||
{
|
||||
var c = construction;
|
||||
double seconds;
|
||||
if (targetLevel <= 1) seconds = c.buildSeconds;
|
||||
else
|
||||
{
|
||||
var t = c.upgradeSecondsToLevel;
|
||||
seconds = t == null || t.Length == 0 ? c.buildSeconds : t[Mathf.Clamp(targetLevel - 2, 0, t.Length - 1)];
|
||||
}
|
||||
if (townHall) seconds *= Mathf.Max(0.01f, c.townHallTimeMultiplier);
|
||||
return (long)(Math.Max(0, seconds) * 1000.0);
|
||||
}
|
||||
|
||||
// Сколько ресурса не отнять при грабеже: часть запаса, которую прячут Склады и Амбары
|
||||
public float GetProtected(ResourceType type, int warehouseLevels, int granaryLevels)
|
||||
{
|
||||
var p = protection;
|
||||
switch (type)
|
||||
{
|
||||
case ResourceType.Wood:
|
||||
case ResourceType.Stone:
|
||||
case ResourceType.Iron:
|
||||
case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels;
|
||||
case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels;
|
||||
case ResourceType.Gems: return float.PositiveInfinity; // без «Грабежа» кристаллы не грабятся (с ним -- ResourceProtection)
|
||||
default: return 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Цена улучшения здания с уровня level на level + 1
|
||||
public ResourceAmount[] UpgradeCost(int level)
|
||||
{
|
||||
|
||||
@@ -22,12 +22,40 @@ public static class GameModules
|
||||
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)));
|
||||
|
||||
// Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig)
|
||||
var marchConfig = MarchConfig.Load();
|
||||
MarchManager.SpeedRules = marchConfig.ToSpeedRules();
|
||||
MarchManager.PointRules = WorldResourceConfig.Load().ToRules(); // ресурсные точки (регламент 31.10)
|
||||
MarchManager.Patrols = marchConfig.ToPatrolRules(); // патрули-заглушки соседей (Разведка М1)
|
||||
|
||||
// Разведка -- чистый C#: туман, обзор, чужие армии (секция "intelligence"). Карту и армии читает у Мира
|
||||
// через IWorldMap; досчитывает наблюдение перед каждым расчётом мира (WorldAdvancingEvent)
|
||||
var intelConfig = IntelligenceConfig.Load();
|
||||
var intelligence = new IntelligenceService(intelConfig.ToRules());
|
||||
GameServices.Register<IIntelligence>(intelligence);
|
||||
SaveRegistry.Register(intelligence);
|
||||
EventBus.Subscribe<WorldAdvancingEvent>(e => intelligence.ObserveUntil(e.ToMs));
|
||||
// «Обзор» для теста, пока нет дерева технологий (после загрузки и в новой игре)
|
||||
void TestObservation() { if (intelConfig.testObservationLevel > 0) intelligence.ObservationLevel = intelConfig.testObservationLevel; }
|
||||
TestObservation();
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => TestObservation());
|
||||
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
|
||||
|
||||
// Армия -- чистый C#, единственное место хранения войск (секция "army")
|
||||
var army = new ArmyService();
|
||||
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules()); // госпиталь (решения 31.10, п. 7)
|
||||
GameServices.Register<IArmy>(army);
|
||||
SaveRegistry.Register(army);
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); });
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyArmy(army));
|
||||
// Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится
|
||||
EventBus.Subscribe<TroopsTrainedEvent>(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); });
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => MigrateLegacyTraining(army));
|
||||
// Раненые вылечены -- вернулись в город
|
||||
EventBus.Subscribe<SoldiersHealedEvent>(e =>
|
||||
{
|
||||
if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Вылечены в госпитале: " + e.Count + " × " + GameData.Troops[e.Type].name);
|
||||
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
|
||||
});
|
||||
|
||||
// Население -- чистый C#: жители, рабочие, еда, прирост (секция "population"). Числа -- из EconomyConfig.
|
||||
var config = EconomyConfig.Load();
|
||||
@@ -52,6 +80,18 @@ public static class GameModules
|
||||
StorageBuilding.Tapped += BuildingWindow.OnStorageTapped;
|
||||
TownHall.Tapped -= BuildingWindow.OnTownHallTapped;
|
||||
TownHall.Tapped += BuildingWindow.OnTownHallTapped;
|
||||
|
||||
// Стройка и улучшение по времени, строители (01.11, ТЕСТ): числа -- EconomyConfig, раздел "Стройка"
|
||||
BuildTimer.Builders = Mathf.Max(1, config.construction.builders);
|
||||
ConstructionClock.Init(); // офлайн: стройка вступает в силу в момент своего завершения (решения 04.11)
|
||||
BuildTimer.DurationMs = (townHall, level) => EconomyConfig.Load().ConstructionMs(townHall, level);
|
||||
BuildTimer.Tapped -= OnBuildTimerTapped;
|
||||
BuildTimer.Tapped += OnBuildTimerTapped;
|
||||
BuildTimer.Completed -= OnBuildCompleted;
|
||||
BuildTimer.Completed += OnBuildCompleted;
|
||||
|
||||
// Защита ресурсов от грабежа (01.11, ТЕСТ): сколько у города нельзя унести в набеге
|
||||
GameServices.Register<IResourceProtection>(new ResourceProtection());
|
||||
EventBus.Subscribe<GameLoadedEvent>(_ => population.OnGameLoaded(GameClock.UnixNow)); // офлайн-прогресс
|
||||
EventBus.Subscribe<GameTimeAdvancedEvent>(e => population.Tick(e.UnixNow)); // перемотка (отладка)
|
||||
EventBus.Subscribe<TroopsChangedEvent>(_ => population.Changed()); // воины -- тоже люди
|
||||
@@ -71,6 +111,13 @@ public static class GameModules
|
||||
// Казармы появляются в списке стройки кодом -- до восстановления зданий из сохранения
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => BarracksBootstrap.Ensure());
|
||||
|
||||
// Госпиталь (решения 31.10, п. 7): здание города даёт места для лечения Армии через контракт IHospital
|
||||
GameServices.Register<IHospital>(new HospitalBeds());
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => HospitalBootstrap.Ensure());
|
||||
|
||||
// Терем богатыря: дом героя, герой гуляет по городу (Meta/Build/HeroCastle, HeroWalker)
|
||||
EventBus.Subscribe<SettlementRestoringEvent>(_ => HeroCastleBootstrap.Ensure());
|
||||
|
||||
// Статистика для заданий (GameProgress) -- по событиям Мира
|
||||
EventBus.Subscribe<MarchStartedEvent>(OnMarchStarted);
|
||||
EventBus.Subscribe<MarchReturnedEvent>(OnMarchReturned);
|
||||
@@ -80,6 +127,21 @@ public static class GameModules
|
||||
EventBus.Subscribe<MarchReturnedEvent>(MarchReturnPopup.OnMarchReturned);
|
||||
}
|
||||
|
||||
static void OnBuildTimerTapped(BuildTimer timer)
|
||||
{
|
||||
if (timer != null) BuildingWindow.Open(timer.gameObject);
|
||||
}
|
||||
|
||||
static void OnBuildCompleted(BuildTimer timer)
|
||||
{
|
||||
if (timer == null) return;
|
||||
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
|
||||
var id = timer.GetComponent<PlacedBuildingId>();
|
||||
string name = timer.GetComponent<TownHall>() != null ? "Ратуша" : id != null ? id.buildingName : timer.name;
|
||||
UIKit.Toast(timer.IsConstruction ? $"Построено: {name}" : $"{name}: улучшено до {timer.targetLevel} уровня");
|
||||
BuildingWindow.OnBuildCompleted(timer);
|
||||
}
|
||||
|
||||
// Сцена загружена, Awake всех объектов прошли, Start -- ещё нет (сохранение грузится в Start)
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void MigrateLegacyMarches()
|
||||
@@ -135,6 +197,36 @@ public static class GameModules
|
||||
GameProgress.Instance.MarkChanged();
|
||||
}
|
||||
|
||||
// Очередь обучения раньше вёл GameProgress (progress.json, Data.training, время -- тики реальных часов).
|
||||
// Теперь -- Армия (save.json, "army", мс часов игры). Переносим один раз, с тем же оставшимся временем.
|
||||
static void MigrateLegacyTraining(ArmyService army)
|
||||
{
|
||||
var data = GameProgress.Instance.Data;
|
||||
if (data.training == null || data.training.Count == 0) return;
|
||||
|
||||
// В Армии уже есть заказы -- перенос уже был (сохранение записалось, а progress.json -- ещё нет): только убрать старое
|
||||
if (army.Training.Count == 0)
|
||||
{
|
||||
long epochTicks = new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc).Ticks;
|
||||
long offsetMs = GameClock.OffsetSeconds * 1000; // старые тики -- реальное время, Армия -- часы игры
|
||||
var orders = new System.Collections.Generic.List<(int, int, long, long, bool)>();
|
||||
foreach (var o in data.training)
|
||||
orders.Add((o.troopIndex, o.count, (o.startTicks - epochTicks) / 10_000 + offsetMs, (o.endTicks - epochTicks) / 10_000 + offsetMs, o.recruited));
|
||||
army.ImportLegacyTraining(orders);
|
||||
Debug.Log($"[МОДУЛИ] Очередь обучения перенесена из progress.json в модуль Армии: заказов {orders.Count}.");
|
||||
}
|
||||
|
||||
// Из progress.json убираем, только когда очередь уже записана в save.json -- иначе вылет игры её потеряет
|
||||
EventBus.Subscribe<GameSavedEvent>(ClearLegacyTraining);
|
||||
}
|
||||
|
||||
static void ClearLegacyTraining(GameSavedEvent e)
|
||||
{
|
||||
EventBus.Unsubscribe<GameSavedEvent>(ClearLegacyTraining);
|
||||
GameProgress.Instance.Data.training.Clear();
|
||||
GameProgress.Instance.MarkChanged();
|
||||
}
|
||||
|
||||
// ---------------- Население и экономика ----------------
|
||||
|
||||
static PopulationRules ToPopulationRules(EconomyConfig config)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА ГОСПИТАЛЯ: места и время лечения раненых (регламент 31.10, решения по 8 вопросам Саши от 31.10, п. 7). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/HospitalConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> Hospital Config, имя файла -- HospitalConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль Армии таблицу не видит: App/GameModules передаёт числа в ArmyService.
|
||||
// Места даёт здание «Госпиталь» (Meta/Build/Hospital.cs) через контракт IHospital.
|
||||
[CreateAssetMenu(fileName = "HospitalConfig", menuName = "Bogatyri/Hospital Config")]
|
||||
public class HospitalConfig : ScriptableObject
|
||||
{
|
||||
[Header("Здание «Госпиталь» (ТЕСТ)")]
|
||||
[Tooltip("Мест для лечения по уровням госпиталя I..V. Регламент 31.10: 20..60. Число строк -- число уровней")]
|
||||
public int[] bedsByLevel = { 20, 30, 40, 50, 60 };
|
||||
[Tooltip("Мест, пока в городе нет госпиталя. Решения 01.11, п. 3: 5. После постройки заменяются штатными, не складываются")]
|
||||
public int temporaryBeds = 5;
|
||||
[Tooltip("С какого уровня Ратуши можно строить")]
|
||||
public int requiredTownHallLevel = 1;
|
||||
[Tooltip("Цена постройки: дерево")]
|
||||
public int buildWood = 250;
|
||||
[Tooltip("Цена постройки: камень")]
|
||||
public int buildStone = 150;
|
||||
|
||||
[Header("Лечение одного воина, ИГРОВЫХ часов (1 игровой час = 5 реальных минут)")]
|
||||
[Tooltip("Пехота, лучники, кавалерия, осадные. Регламент 31.10: 4 / 4 / 6 / 8")]
|
||||
public int[] healGameHours = { 4, 4, 6, 8 };
|
||||
|
||||
[Header("Цена лечения одного воина (решения 01.11, п. 3: золото 1 / 1 / 2 / 3)")]
|
||||
[Tooltip("Еды за воина: пехота, лучники, кавалерия, осадные")]
|
||||
public int[] healFoodPerSoldier = { 0, 0, 0, 0 };
|
||||
[Tooltip("Золота за воина: пехота, лучники, кавалерия, осадные")]
|
||||
public int[] healGoldPerSoldier = { 1, 1, 2, 3 };
|
||||
|
||||
static HospitalConfig cached;
|
||||
|
||||
public static HospitalConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<HospitalConfig>("HospitalConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[ГОСПИТАЛЬ] Нет Assets/Resources/HospitalConfig.asset -- берутся числа по умолчанию из HospitalConfig.cs.");
|
||||
cached = CreateInstance<HospitalConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public HospitalRules ToRules()
|
||||
{
|
||||
var hours = new int[TroopType.Count];
|
||||
var cost = new ResourceAmount[TroopType.Count][];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
hours[i] = healGameHours != null && i < healGameHours.Length ? Mathf.Max(0, healGameHours[i]) : 4;
|
||||
int food = healFoodPerSoldier != null && i < healFoodPerSoldier.Length ? Mathf.Max(0, healFoodPerSoldier[i]) : 0;
|
||||
int gold = healGoldPerSoldier != null && i < healGoldPerSoldier.Length ? Mathf.Max(0, healGoldPerSoldier[i]) : 0;
|
||||
cost[i] = new[] { new ResourceAmount(ResourceType.Food, food), new ResourceAmount(ResourceType.Gold, gold) };
|
||||
}
|
||||
return new HospitalRules
|
||||
{
|
||||
TemporaryBeds = Mathf.Max(0, temporaryBeds),
|
||||
HealGameHours = hours,
|
||||
HealCostPerSoldier = cost
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c25bbe993b314a15a6d1a8aaf0017d55
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,73 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА РАЗВЕДКИ, этап М1: радиусы обзора, последний контакт, примерная численность, опознание (ТЗ разведки 31.10).
|
||||
// Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/IntelligenceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> Intelligence Config, имя файла -- IntelligenceConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль таблицу не видит: App/GameModules передаёт числа в модуль Разведки.
|
||||
// Патрули-заглушки соседей включаются в таблице MarchConfig (это походы Мира).
|
||||
[CreateAssetMenu(fileName = "IntelligenceConfig", menuName = "Bogatyri/Intelligence Config")]
|
||||
public class IntelligenceConfig : ScriptableObject
|
||||
{
|
||||
[Header("Радиус обзора, клеток карты")]
|
||||
[Tooltip("Своё поселение. ТЗ разведки: 5")]
|
||||
public float settlementRadius = 5f;
|
||||
[Tooltip("Обычная армия в походе. ТЗ разведки: 1")]
|
||||
public float armyRadius = 1f;
|
||||
|
||||
[Header("Технология «Обзор» (не складывается, уровень заменяет)")]
|
||||
[Tooltip("Прибавка к радиусам на уровнях I, II, III, %. ТЗ: 10 / 20 / 30")]
|
||||
public int[] observationPercent = { 10, 20, 30 };
|
||||
[Tooltip("ТОЛЬКО ДЛЯ ТЕСТА: уровень «Обзора» 0..3, пока нет дерева технологий")]
|
||||
public int testObservationLevel = 0;
|
||||
|
||||
[Header("Чужие армии")]
|
||||
[Tooltip("Метка последнего контакта живёт, РЕАЛЬНЫХ минут. ТЗ: 10")]
|
||||
public float lastContactMinutes = 10f;
|
||||
[Tooltip("Примерная численность: ±%. ТЗ: 20")]
|
||||
public int approxPercent = 20;
|
||||
[Tooltip("Шанс опознать владельца за одно событие наблюдения, %. ТЗ: 50")]
|
||||
public int identifyPercent = 50;
|
||||
[Tooltip("Шаг расчёта наблюдения за время, пока игра была закрыта, РЕАЛЬНЫХ секунд")]
|
||||
public float sampleStepSeconds = 10f;
|
||||
|
||||
[Header("Туман на карте")]
|
||||
[Tooltip("Выключено (по умолчанию): города, лагеря и точки видны всегда, туман только затемняет землю. " +
|
||||
"Включено: неисследованная местность закрыта полностью вместе с объектами. Решения 31.10, п. 1: для М1 не требуется, оставлено переключателем")]
|
||||
public bool hideUnexploredObjects = false;
|
||||
|
||||
static IntelligenceConfig cached;
|
||||
|
||||
public static IntelligenceConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<IntelligenceConfig>("IntelligenceConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[РАЗВЕДКА] Нет Assets/Resources/IntelligenceConfig.asset -- берутся числа по умолчанию из IntelligenceConfig.cs.");
|
||||
cached = CreateInstance<IntelligenceConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public IntelligenceRules ToRules()
|
||||
{
|
||||
var levels = new int[4];
|
||||
for (int i = 0; i < 3 && observationPercent != null && i < observationPercent.Length; i++) levels[i + 1] = Mathf.Max(0, observationPercent[i]);
|
||||
return new IntelligenceRules
|
||||
{
|
||||
SettlementRadius = Milli(settlementRadius),
|
||||
ArmyRadius = Milli(armyRadius),
|
||||
ObservationPercent = levels,
|
||||
LastContactMs = (long)(Mathf.Max(0f, lastContactMinutes) * 60_000f),
|
||||
ApproxPercent = Mathf.Clamp(approxPercent, 0, 100),
|
||||
IdentifyPercent = Mathf.Clamp(identifyPercent, 0, 100),
|
||||
SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f)
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float cells) => Mathf.Max(0, Mathf.RoundToInt(cells * 1000f));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c249ad34e2f94460b9f99b49c71760c8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,82 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА МАРШЕЙ: скорости войск по типам и слоты похода (регламент 31.10, раздел 7). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/MarchConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> March Config, имя файла -- MarchConfig, папка -- Resources.
|
||||
//
|
||||
// Модули таблицу не видят: App/GameModules передаёт скорости в модуль Мира, слоты -- в модуль Армии.
|
||||
// Бонусы героя (+15%) и технологии (+20%) к скорости появятся вместе с героями и технологиями.
|
||||
[CreateAssetMenu(fileName = "MarchConfig", menuName = "Bogatyri/March Config")]
|
||||
public class MarchConfig : ScriptableObject
|
||||
{
|
||||
[Header("Скорость: клеток карты за 1 РЕАЛЬНЫЙ час (марш идёт в реальном времени)")]
|
||||
[Tooltip("Пехота. Регламент 31.10: 8")]
|
||||
public float infantry = 8f;
|
||||
[Tooltip("Лучники. Регламент 31.10: 9")]
|
||||
public float archers = 9f;
|
||||
[Tooltip("Кавалерия. Регламент 31.10: 12")]
|
||||
public float cavalry = 12f;
|
||||
[Tooltip("Осадные. Регламент 31.10: 4")]
|
||||
public float siege = 4f;
|
||||
|
||||
[Header("Модификаторы скорости, % (решения 31.10, п. 4: смешанное войско -- средняя по численности)")]
|
||||
[Tooltip("По типам: пехота, лучники, кавалерия, осадные. +10 -- быстрее на 10%. Позже -- бонусы героев и технологий. ТЕСТ")]
|
||||
public int[] typeSpeedBonusPercent = { 0, 0, 0, 0 };
|
||||
[Tooltip("Общий модификатор всего войска, применяется один раз. ТЕСТ")]
|
||||
public int armySpeedBonusPercent = 0;
|
||||
|
||||
[Header("Слоты похода")]
|
||||
[Tooltip("Сколько походов игрока могут идти одновременно. Регламент 31.10: 2 (технология «Военная логистика» -- до 7)")]
|
||||
public int baseMarchSlots = 2;
|
||||
|
||||
[Header("Патрули-заглушки соседей (Разведка М1, ВРЕМЕННАЯ тестовая механика)")]
|
||||
[Tooltip("Соседние города иногда отправляют отряды к другим городам и лагерям -- проверить обнаружение, численность, опознание и потерю контакта. Выключить, когда появится настоящий PvP")]
|
||||
public bool patrolsEnabled = true;
|
||||
[Tooltip("Сколько патрулей одновременно на карте")]
|
||||
public int patrolMaxActive = 3;
|
||||
[Tooltip("Новый патруль -- раз в столько РЕАЛЬНЫХ минут (если не превышен предел)")]
|
||||
public float patrolIntervalMinutes = 10f;
|
||||
public int patrolSoldiersMin = 10;
|
||||
public int patrolSoldiersMax = 40;
|
||||
|
||||
static MarchConfig cached;
|
||||
|
||||
public static MarchConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<MarchConfig>("MarchConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[МАРШИ] Нет Assets/Resources/MarchConfig.asset -- берутся числа по умолчанию из MarchConfig.cs.");
|
||||
cached = CreateInstance<MarchConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Скорости для модуля Мира: милли-единиц карты (1000 = клетка) за реальный час
|
||||
public Bogatyri.World.MarchSpeedRules ToSpeedRules()
|
||||
{
|
||||
return new Bogatyri.World.MarchSpeedRules
|
||||
{
|
||||
SpeedMilliPerHour = new[] { Milli(infantry), Milli(archers), Milli(cavalry), Milli(siege) },
|
||||
TypeBonusPercent = (int[])(typeSpeedBonusPercent ?? new int[4]).Clone(),
|
||||
ArmyBonusPercent = armySpeedBonusPercent
|
||||
};
|
||||
}
|
||||
|
||||
public Bogatyri.World.PatrolRules ToPatrolRules()
|
||||
{
|
||||
return new Bogatyri.World.PatrolRules
|
||||
{
|
||||
Enabled = patrolsEnabled,
|
||||
MaxActive = Mathf.Max(0, patrolMaxActive),
|
||||
IntervalMs = (long)(Mathf.Max(1f, patrolIntervalMinutes) * 60_000f),
|
||||
SoldiersMin = Mathf.Max(1, patrolSoldiersMin),
|
||||
SoldiersMax = Mathf.Max(1, patrolSoldiersMax)
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float cellsPerHour) => Mathf.Max(1, Mathf.RoundToInt(cellsPerHour * 1000f));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0427eef4612041d2b8a359f9bf358779
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,33 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Защита ресурсов от грабежа (контракт IResourceProtection, 01.11, ТЕСТ).
|
||||
// Часть запаса прячут Склады и Амбары: EconomyConfig, раздел "Защита ресурсов". Уровни зданий сообщает
|
||||
// App/CitySync; на карте мира, где города нет, действуют последние известные уровни.
|
||||
public sealed class ResourceProtection : IResourceProtection
|
||||
{
|
||||
public static int WarehouseLevels { get; private set; }
|
||||
public static int GranaryLevels { get; private set; }
|
||||
|
||||
public static void SetLevels(int warehouseLevels, int granaryLevels)
|
||||
{
|
||||
WarehouseLevels = Mathf.Max(0, warehouseLevels);
|
||||
GranaryLevels = Mathf.Max(0, granaryLevels);
|
||||
}
|
||||
|
||||
public float Protected(ResourceType type)
|
||||
{
|
||||
return EconomyConfig.Load().GetProtected(type, WarehouseLevels, GranaryLevels);
|
||||
}
|
||||
|
||||
public float Lootable(ResourceType type) => Lootable(type, false);
|
||||
|
||||
// Кристаллы: без «Грабежа» -- 0; с ним -- не больше доли запаса (EconomyConfig, ТЕСТ, точные ограничения -- позже)
|
||||
public float Lootable(ResourceType type, bool attackerHasPlunder)
|
||||
{
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return 0f;
|
||||
float have = Mathf.Max(0f, economy.Get(type));
|
||||
if (type == ResourceType.Gems)
|
||||
return attackerHasPlunder ? Mathf.Floor(have * Mathf.Clamp01(EconomyConfig.Load().protection.plunderGemsShare)) : 0f;
|
||||
return Mathf.Max(0f, have - Protected(type));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cea38890d8f5d8d4f804c095c4c113b4
|
||||
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ТАБЛИЦА РЕСУРСНЫХ ТОЧЕК (регламент 31.10, раздел 7: «Изменять числа через WorldResourceConfig.asset»). Числа -- ТЕСТ.
|
||||
//
|
||||
// Ассет: Assets/Resources/WorldResourceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
|
||||
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
|
||||
// Create -> Bogatyri -> World Resource Config, имя файла -- WorldResourceConfig, папка -- Resources.
|
||||
//
|
||||
// Модуль Мира таблицу не видит: App/GameModules переводит её в ResourcePointRules.
|
||||
// Состояние точек (запас, сроки) ведёт Мир; зачисление добычи и складские лимиты -- Экономика (решение №46).
|
||||
[CreateAssetMenu(fileName = "WorldResourceConfig", menuName = "Bogatyri/World Resource Config")]
|
||||
public class WorldResourceConfig : ScriptableObject
|
||||
{
|
||||
[Header("Запас точки: малая / средняя / крупная")]
|
||||
public int[] woodStock = { 200, 600, 1500 };
|
||||
public int[] stoneStock = { 150, 450, 1000 };
|
||||
public int[] metalStock = { 80, 250, 600 };
|
||||
public int[] goldStock = { 30, 100, 250 };
|
||||
|
||||
[Header("Добыча одного воина за ИГРОВОЙ час (1 игровой час = 5 реальных минут)")]
|
||||
public float woodPerGameHour = 2f;
|
||||
public float stonePerGameHour = 1.5f;
|
||||
public float metalPerGameHour = 1f;
|
||||
public float goldPerGameHour = 0.5f;
|
||||
|
||||
[Header("Груз одного воина (осадные машины груза не несут)")]
|
||||
public int woodCargo = 12;
|
||||
public int stoneCargo = 10;
|
||||
public int metalCargo = 8;
|
||||
public int goldCargo = 5;
|
||||
|
||||
[Header("Срок жизни точки, РЕАЛЬНЫХ часов с момента появления: малая / средняя / крупная")]
|
||||
public int[] lifetimeRealHours = { 12, 24, 48 };
|
||||
|
||||
[Header("Сколько точек на карте: малых / средних / крупных (решения 31.10, п. 6, ТЕСТ)")]
|
||||
[Tooltip("Решения 31.10: всего 24 -- лес 8, камень 7, металл 5, золото 4; по размерам 12 / 8 / 4. Раскладка по ресурсам -- здесь")]
|
||||
public int[] woodCount = { 4, 3, 1 };
|
||||
public int[] stoneCount = { 4, 2, 1 };
|
||||
public int[] metalCount = { 2, 2, 1 };
|
||||
public int[] goldCount = { 2, 1, 1 };
|
||||
|
||||
static WorldResourceConfig cached;
|
||||
|
||||
public static WorldResourceConfig Load()
|
||||
{
|
||||
if (cached != null) return cached;
|
||||
cached = Resources.Load<WorldResourceConfig>("WorldResourceConfig");
|
||||
if (cached == null)
|
||||
{
|
||||
Debug.LogWarning("[РЕСУРСНЫЕ ТОЧКИ] Нет Assets/Resources/WorldResourceConfig.asset -- берутся числа по умолчанию.");
|
||||
cached = CreateInstance<WorldResourceConfig>();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
public Bogatyri.World.ResourcePointRules ToRules()
|
||||
{
|
||||
var d = Bogatyri.World.ResourcePointRules.Default;
|
||||
return new Bogatyri.World.ResourcePointRules
|
||||
{
|
||||
Stock = new[] { Three(woodStock, d.Stock[0]), Three(stoneStock, d.Stock[1]), Three(metalStock, d.Stock[2]), Three(goldStock, d.Stock[3]) },
|
||||
MiningMilliPerGameHour = new[] { Milli(woodPerGameHour), Milli(stonePerGameHour), Milli(metalPerGameHour), Milli(goldPerGameHour) },
|
||||
CargoPerSoldier = new[] { Mathf.Max(0, woodCargo), Mathf.Max(0, stoneCargo), Mathf.Max(0, metalCargo), Mathf.Max(0, goldCargo) },
|
||||
LifetimeRealHours = Three(lifetimeRealHours, d.LifetimeRealHours),
|
||||
Count = new[] { Counts(woodCount, d.Count[0]), Counts(stoneCount, d.Count[1]), Counts(metalCount, d.Count[2]), Counts(goldCount, d.Count[3]) }
|
||||
};
|
||||
}
|
||||
|
||||
static int Milli(float perHour) => Mathf.Max(0, Mathf.RoundToInt(perHour * 1000f));
|
||||
|
||||
// Число точек: ровно три значения, не меньше 0 (0 -- такого размера нет), иначе -- значения по умолчанию
|
||||
static int[] Counts(int[] values, int[] fallback)
|
||||
{
|
||||
if (values == null || values.Length != 3) return (int[])fallback.Clone();
|
||||
return new[] { Mathf.Max(0, values[0]), Mathf.Max(0, values[1]), Mathf.Max(0, values[2]) };
|
||||
}
|
||||
|
||||
// Ровно три значения (малая / средняя / крупная), иначе -- значения по умолчанию
|
||||
static int[] Three(int[] values, int[] fallback)
|
||||
{
|
||||
if (values == null || values.Length != 3) return (int[])fallback.Clone();
|
||||
return new[] { Mathf.Max(1, values[0]), Mathf.Max(1, values[1]), Mathf.Max(1, values[2]) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ee3759b377140c0b759a4a386327510
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -66,16 +66,16 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
|
||||
void Refresh()
|
||||
{
|
||||
if (label == null) return;
|
||||
var d = GameProgress.Instance.Data;
|
||||
if (d.training.Count == 0)
|
||||
var training = GameProgress.Instance.Training;
|
||||
if (training.Count == 0)
|
||||
{
|
||||
label.text = "Казармы\n<size=70%>нажмите, чтобы нанять</size>";
|
||||
return;
|
||||
}
|
||||
var o = d.training[0];
|
||||
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.troopIndex].name + " ×" + o.count + " " +
|
||||
var o = training[0];
|
||||
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.Type].name + " ×" + o.Count + " " +
|
||||
GameData.FormatTime(GameProgress.Instance.TrainingSecondsLeft(o)) +
|
||||
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
|
||||
(training.Count > 1 ? " (+" + (training.Count - 1) + ")" : "") + "</size>";
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
@@ -13,6 +13,10 @@ using TMPro;
|
||||
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
|
||||
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
|
||||
//
|
||||
// 6. Лимит зданий по уровню Ратуши (EconomyConfig, ТЕСТ): в списке видно "2 / 3", сверх лимита строить нельзя.
|
||||
// 7. Стройка идёт по времени и занимает строителя (BuildTimer): все заняты -- поставить нельзя.
|
||||
// 8. Перенос (кнопка "Перенести" в окне здания): тот же призрак, но здание переезжает бесплатно.
|
||||
//
|
||||
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
|
||||
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
|
||||
public class BuildModeController : MonoBehaviour
|
||||
@@ -33,6 +37,7 @@ public class BuildModeController : MonoBehaviour
|
||||
Button placeButton;
|
||||
|
||||
BuildGhost ghost;
|
||||
GameObject moving; // здание, которое переносим (null -- строим новое)
|
||||
bool dragging;
|
||||
Vector2 grabOffset;
|
||||
Vector2 downPos;
|
||||
@@ -145,6 +150,32 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
// ---------------- призрак ----------------
|
||||
|
||||
// Перенос построенного здания: призрак на его месте, "Сюда" -- переезжает бесплатно
|
||||
public void StartMove(GameObject building)
|
||||
{
|
||||
var id = building != null ? building.GetComponent<PlacedBuildingId>() : null;
|
||||
var data = id != null && BuildManager.Instance != null ? BuildManager.Instance.FindData(id.buildingName) : null;
|
||||
if (data == null) { UIKit.Toast("Это здание перенести нельзя"); return; }
|
||||
|
||||
if (!IsActive)
|
||||
{
|
||||
IsActive = true;
|
||||
UpdateButtonLabel();
|
||||
BuildGrid.Instance.ShowOverlay(true);
|
||||
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
|
||||
if (banner == null) banner = CreateBanner();
|
||||
}
|
||||
CloseChooser();
|
||||
CancelGhost();
|
||||
|
||||
moving = building;
|
||||
BuildGrid.IgnoredBuilding = building; // его клетки считаются свободными, пока выбираем новое место
|
||||
BuildGrid.Instance.Refresh();
|
||||
ghost = BuildGhost.Create(data);
|
||||
ghost.MoveNear((Vector2)building.transform.position + ghost.Footprint.anchorOffset);
|
||||
CreatePlacePanel();
|
||||
}
|
||||
|
||||
void StartGhost(BuildingData data)
|
||||
{
|
||||
CancelGhost();
|
||||
@@ -166,6 +197,12 @@ public class BuildModeController : MonoBehaviour
|
||||
void CancelGhost()
|
||||
{
|
||||
StopDrag();
|
||||
if (moving != null || BuildGrid.IgnoredBuilding != null)
|
||||
{
|
||||
moving = null;
|
||||
BuildGrid.IgnoredBuilding = null; // здание остаётся на старом месте
|
||||
if (BuildGrid.Instance != null) BuildGrid.Instance.Refresh();
|
||||
}
|
||||
if (ghost != null) Destroy(ghost.gameObject);
|
||||
ghost = null;
|
||||
if (placePanel != null) Destroy(placePanel);
|
||||
@@ -178,11 +215,28 @@ public class BuildModeController : MonoBehaviour
|
||||
var d = ghost.Data;
|
||||
ghost.Recheck();
|
||||
if (!ghost.CellsFree) { UIKit.Toast("Здесь строить нельзя: клетки заняты"); return; }
|
||||
|
||||
if (moving != null)
|
||||
{
|
||||
var building = moving;
|
||||
var id = building != null ? building.GetComponent<PlacedBuildingId>() : null;
|
||||
if (id == null || string.IsNullOrEmpty(id.buildingName)) { CancelGhost(); return; } // здания уже нет
|
||||
// Перенос бесплатный: меняется только место. Уровень, стройка и улучшение остаются у того же здания,
|
||||
// ресурсы не тратятся и не возвращаются (решения 04.11)
|
||||
building.transform.position = ghost.PrefabPosition;
|
||||
CancelGhost(); // снимает "игнор" клеток -- здание уже на новом месте
|
||||
UIKit.Toast("Перенесено: " + d.buildingName);
|
||||
return;
|
||||
}
|
||||
|
||||
string limit = LimitProblem(d);
|
||||
if (limit != null) { UIKit.Toast(limit); return; }
|
||||
if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
|
||||
if (!d.CanAfford()) { UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); return; }
|
||||
|
||||
if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null)
|
||||
{
|
||||
UIKit.Toast("Построено: " + d.buildingName);
|
||||
UIKit.Toast(BuildTimer.DurationMs(false, 1) > 0 ? "Стройка началась: " + d.buildingName : "Построено: " + d.buildingName);
|
||||
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
|
||||
}
|
||||
else UIKit.Toast("Не получилось построить");
|
||||
@@ -202,8 +256,8 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
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);
|
||||
placeButton = UIKit.Button(bar, moving != null ? "Сюда" : "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
|
||||
if (moving == null) UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
|
||||
UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26);
|
||||
RefreshPlacePanel();
|
||||
}
|
||||
@@ -212,12 +266,22 @@ public class BuildModeController : MonoBehaviour
|
||||
{
|
||||
if (ghost == null || placeInfo == null) return;
|
||||
var d = ghost.Data;
|
||||
bool afford = d.CanAfford();
|
||||
string where = ghost.CellsFree ? "<color=#9be59b>место свободно</color>" : "<color=#ff7a7a>клетки заняты</color>";
|
||||
if (moving != null)
|
||||
{
|
||||
placeInfo.text = "<b>Перенос: " + d.buildingName + "</b> " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y +
|
||||
" кл. • " + where + "\nБесплатно";
|
||||
UIKit.SetButton(placeButton, ghost.CellsFree ? "Сюда" : "Занято", ghost.CellsFree);
|
||||
return;
|
||||
}
|
||||
bool afford = d.CanAfford();
|
||||
string limit = LimitProblem(d);
|
||||
bool builders = BuildTimer.DurationMs(false, 1) <= 0 || BuildTimer.FreeBuilders > 0;
|
||||
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);
|
||||
" кл. • " + where + "\n" + CostText(d) + BuildTimeText();
|
||||
bool ok = ghost.CellsFree && afford && limit == null && builders;
|
||||
string label = ok ? "Поставить" : !ghost.CellsFree ? "Занято" : limit != null ? "Лимит" : !builders ? "Строители заняты" : "Мало ресурсов";
|
||||
UIKit.SetButton(placeButton, label, ok);
|
||||
}
|
||||
|
||||
// ---------------- окно выбора здания ----------------
|
||||
@@ -239,7 +303,8 @@ public class BuildModeController : MonoBehaviour
|
||||
card.offsetMin = Vector2.zero; card.offsetMax = Vector2.zero;
|
||||
|
||||
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
|
||||
UIKit.Text(card, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, "Ратуша " + th + " ур. • строителей свободно: " + BuildTimer.FreeBuilders + " из " + BuildTimer.Builders +
|
||||
"\nвыберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
var holder = UIKit.Make("ListHolder", card);
|
||||
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
|
||||
@@ -254,7 +319,10 @@ public class BuildModeController : MonoBehaviour
|
||||
foreach (var data in all)
|
||||
{
|
||||
var d = data;
|
||||
bool locked = d.requiredTownHallLevel > th;
|
||||
int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th);
|
||||
int have = CountBuilt(d.buildingName);
|
||||
bool full = max >= 0 && have >= max;
|
||||
bool locked = d.requiredTownHallLevel > th || full;
|
||||
bool afford = !locked && d.CanAfford();
|
||||
var size = BuildFootprint.Of(d).size;
|
||||
|
||||
@@ -265,13 +333,15 @@ public class BuildModeController : MonoBehaviour
|
||||
|
||||
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>", 30,
|
||||
string count = max >= 0 ? " <size=22><color=#aab>построено " + have + " / " + max + "</color></size>" : "";
|
||||
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>" + count, 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 why = d.requiredTownHallLevel > th ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня"
|
||||
: full ? NextLimitText(d.buildingName, th, have) : CostText(d);
|
||||
UIKit.Text(info, why, 24, locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
|
||||
|
||||
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
|
||||
string label = locked ? "Закрыто" : "Выбрать";
|
||||
string label = d.requiredTownHallLevel > th ? "Закрыто" : full ? "Лимит" : "Выбрать";
|
||||
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
|
||||
b.interactable = !locked;
|
||||
}
|
||||
@@ -279,6 +349,43 @@ public class BuildModeController : MonoBehaviour
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
|
||||
}
|
||||
|
||||
// ---------------- лимит зданий по Ратуше (EconomyConfig, ТЕСТ) ----------------
|
||||
|
||||
static int CountBuilt(string buildingName)
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
if (id.buildingName == buildingName) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// null -- лимит позволяет; иначе текст для игрока
|
||||
static string LimitProblem(BuildingData d)
|
||||
{
|
||||
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
|
||||
int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th);
|
||||
int have = CountBuilt(d.buildingName);
|
||||
if (max < 0 || have < max) return null;
|
||||
return NextLimitText(d.buildingName, th, have);
|
||||
}
|
||||
|
||||
static string NextLimitText(string buildingName, int th, int have)
|
||||
{
|
||||
var config = EconomyConfig.Load();
|
||||
for (int lvl = th + 1; lvl <= 10; lvl++)
|
||||
if (config.BuildingLimitFor(buildingName, lvl) > have)
|
||||
return "Лимит " + have + " / " + config.BuildingLimitFor(buildingName, th) + ": больше — на Ратуше " + lvl + " ур.";
|
||||
return "Достигнут лимит: " + have;
|
||||
}
|
||||
|
||||
static string BuildTimeText()
|
||||
{
|
||||
long ms = BuildTimer.DurationMs(false, 1);
|
||||
if (ms <= 0) return "";
|
||||
string free = BuildTimer.FreeBuilders > 0 ? "" : " <color=#ff7a7a>все строители заняты</color>";
|
||||
return " • стройка " + GameData.FormatTime(ms / 1000.0) + free;
|
||||
}
|
||||
|
||||
void CloseChooser()
|
||||
{
|
||||
if (chooser != null) Destroy(chooser);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// ТЕРЕМ БОГАТЫРЯ -- дом героя в городе.
|
||||
// - Пока Терем построен, у его ворот появляется герой и гуляет по свободным клеткам города (HeroWalker).
|
||||
// Тап по герою или по Терему -- окно героя (App/BuildingWindow): уровень, сила, бонус армии, навыки, улучшение.
|
||||
// - Механику героя не меняет: уровень, цена улучшения и бонусы те же, что в панели "Герой" (GameProgress).
|
||||
// Нужен ли Терем для улучшения героя, даёт ли уровни и сколько стоит -- решает Дима (сейчас ТЕСТ).
|
||||
// - Терем один на город: второй ничего не добавляет, герой живёт у первого.
|
||||
// Вешается на копию обычного здания (HeroCastleBootstrap).
|
||||
public class HeroCastle : MonoBehaviour
|
||||
{
|
||||
public static readonly List<HeroCastle> All = new List<HeroCastle>();
|
||||
public static int Count => All.Count;
|
||||
public static HeroCastle Main => All.Count > 0 ? All[0] : null;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
if (!All.Contains(this)) All.Add(this);
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
All.Remove(this);
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (Main == this) HeroWalker.Spawn(this);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
All.Remove(this);
|
||||
HeroWalker.OnCastleRemoved(this);
|
||||
}
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап (а не перетаскивание камеры) -- окно героя
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
BuildingWindow.Open(gameObject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3edb04b487c1554a91d69b16f7876e4
|
||||
@@ -0,0 +1,65 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Добавляет «Терем богатыря» в список стройки САМ при запуске сцены (как Казармы и Госпиталь).
|
||||
// Срабатывает до восстановления зданий из сохранения (SettlementRestoringEvent), поэтому построенный
|
||||
// Терем корректно восстанавливается из save.json. Цена -- ТЕСТ (согласовать с Димой).
|
||||
public static class HeroCastleBootstrap
|
||||
{
|
||||
public const string CastleName = "Терем богатыря";
|
||||
public const int CostWood = 400; // ТЕСТ
|
||||
public const int CostStone = 300; // ТЕСТ
|
||||
static GameObject runtimeTemplate;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Init()
|
||||
{
|
||||
Ensure();
|
||||
UnityEngine.SceneManagement.SceneManager.sceneLoaded += (s, m) => Ensure();
|
||||
}
|
||||
|
||||
public static void Ensure()
|
||||
{
|
||||
var bm = BuildManager.Instance != null ? BuildManager.Instance : Object.FindAnyObjectByType<BuildManager>();
|
||||
if (bm == null || bm.allBuildings == null) return;
|
||||
|
||||
BuildingData template = null;
|
||||
foreach (var b in bm.allBuildings)
|
||||
{
|
||||
if (b == null) continue;
|
||||
if (b.buildingName == CastleName) return; // уже есть
|
||||
if (template == null && b.prefab != null) template = b;
|
||||
}
|
||||
if (template == null) { Debug.LogWarning("[ТЕРЕМ] Нет ни одного здания-шаблона в BuildManager"); return; }
|
||||
|
||||
// Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом HeroCastle
|
||||
if (runtimeTemplate == null)
|
||||
{
|
||||
runtimeTemplate = Object.Instantiate(template.prefab);
|
||||
runtimeTemplate.name = "HeroCastle";
|
||||
runtimeTemplate.SetActive(false);
|
||||
Object.DontDestroyOnLoad(runtimeTemplate);
|
||||
foreach (var c in runtimeTemplate.GetComponents<ResourceCollector>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in runtimeTemplate.GetComponents<ResourceProducer>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in runtimeTemplate.GetComponents<BuildingUI>()) Object.DestroyImmediate(c);
|
||||
for (int i = runtimeTemplate.transform.childCount - 1; i >= 0; i--)
|
||||
Object.DestroyImmediate(runtimeTemplate.transform.GetChild(i).gameObject); // полоска запаса ресурса
|
||||
var sr = runtimeTemplate.GetComponent<SpriteRenderer>();
|
||||
if (sr != null) sr.color = new Color(1f, 0.85f, 0.35f); // пока нет арта -- отличаем цветом (золотой)
|
||||
runtimeTemplate.AddComponent<HeroCastle>();
|
||||
}
|
||||
|
||||
var data = ScriptableObject.CreateInstance<BuildingData>();
|
||||
data.name = "Data_HeroCastle_Runtime";
|
||||
data.buildingName = CastleName;
|
||||
data.icon = template.icon;
|
||||
data.prefab = runtimeTemplate;
|
||||
data.requiredTownHallLevel = 1; // ТЕСТ
|
||||
data.cost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = CostWood },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = CostStone },
|
||||
};
|
||||
bm.allBuildings.Add(data);
|
||||
Debug.Log("[ТЕРЕМ] Добавлен в список стройки");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b1671b6bc9bd9174f9a8f213d07ef757
|
||||
@@ -0,0 +1,203 @@
|
||||
using UnityEngine;
|
||||
|
||||
// ГЕРОЙ В ГОРОДЕ -- живёт у Терема богатыря и гуляет по свободным клеткам рядом с ним.
|
||||
// - Пока без арта: фигурка из простых форм (тело, голова, щит) цвета героя. Арт подставим в BuildFigure.
|
||||
// - Идёт к случайной свободной клетке в радиусе WalkRadius от Терема, стоит 1-3 с, идёт дальше.
|
||||
// - Тап по герою -- он отвечает фразой и открывается окно героя.
|
||||
// - В режиме стройки герой прячется, чтобы не мешать ставить здания.
|
||||
// Это только оживление города: на экономику, бой и сохранение не влияет.
|
||||
public class HeroWalker : MonoBehaviour
|
||||
{
|
||||
public static HeroWalker Instance { get; private set; }
|
||||
|
||||
const float Speed = 1.3f; // клеток в секунду
|
||||
const int WalkRadius = 6; // насколько далеко уходит от Терема
|
||||
const int SortingOrder = 40; // поверх зданий
|
||||
|
||||
static readonly string[] Phrases =
|
||||
{
|
||||
"Город под защитой!",
|
||||
"Веди, воевода, — не подведу.",
|
||||
"Силушка богатырская при мне.",
|
||||
"Дозором обхожу владения.",
|
||||
"Есть ли где ворог? Только скажи.",
|
||||
};
|
||||
|
||||
HeroCastle castle;
|
||||
Vector3 target;
|
||||
float waitLeft;
|
||||
Transform figure;
|
||||
Vector3 downPos;
|
||||
BoxCollider2D tapArea;
|
||||
|
||||
public static void Spawn(HeroCastle home)
|
||||
{
|
||||
if (Instance != null) { Instance.castle = home; return; }
|
||||
var go = new GameObject("Hero");
|
||||
var w = go.AddComponent<HeroWalker>();
|
||||
w.castle = home;
|
||||
w.BuildFigure();
|
||||
go.transform.position = w.Gate();
|
||||
w.target = go.transform.position;
|
||||
w.waitLeft = 1f;
|
||||
Instance = w;
|
||||
}
|
||||
|
||||
public static void OnCastleRemoved(HeroCastle removed)
|
||||
{
|
||||
if (Instance == null || Instance.castle != removed) return;
|
||||
var next = HeroCastle.Main;
|
||||
if (next != null) Instance.castle = next;
|
||||
else Destroy(Instance.gameObject);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
// ---------------- Внешний вид (заглушка) ----------------
|
||||
|
||||
void BuildFigure()
|
||||
{
|
||||
figure = new GameObject("Figure").transform;
|
||||
figure.SetParent(transform, false);
|
||||
Part("Body", Square(), new Color(0.55f, 0.4f, 0.2f), new Vector2(0f, 0.28f), new Vector2(0.38f, 0.48f), 0);
|
||||
Part("Head", Circle(), new Color(1f, 0.85f, 0.7f), new Vector2(0f, 0.66f), new Vector2(0.3f, 0.3f), 1);
|
||||
Part("Helmet", Circle(), new Color(0.75f, 0.75f, 0.8f), new Vector2(0f, 0.76f), new Vector2(0.32f, 0.16f), 2);
|
||||
Part("Shield", Circle(), new Color(0.8f, 0.15f, 0.15f), new Vector2(0.2f, 0.28f), new Vector2(0.22f, 0.3f), 3);
|
||||
Part("Shadow", Circle(), new Color(0f, 0f, 0f, 0.3f), new Vector2(0f, 0.02f), new Vector2(0.45f, 0.14f), -1);
|
||||
|
||||
// Коллайдер для тапа -- чуть больше фигурки, ближе к камере, чем здания
|
||||
tapArea = gameObject.AddComponent<BoxCollider2D>();
|
||||
tapArea.size = new Vector2(0.7f, 1f);
|
||||
tapArea.offset = new Vector2(0f, 0.45f);
|
||||
var p = transform.position; p.z = -0.5f; transform.position = p;
|
||||
}
|
||||
|
||||
SpriteRenderer Part(string name, Sprite sprite, Color color, Vector2 pos, Vector2 size, int order)
|
||||
{
|
||||
var t = new GameObject(name).transform;
|
||||
t.SetParent(figure, false);
|
||||
t.localPosition = pos;
|
||||
t.localScale = new Vector3(size.x, size.y, 1f);
|
||||
var sr = t.gameObject.AddComponent<SpriteRenderer>();
|
||||
sr.sprite = sprite;
|
||||
sr.color = color;
|
||||
sr.sortingOrder = SortingOrder + order;
|
||||
return sr;
|
||||
}
|
||||
|
||||
static Sprite square, circle;
|
||||
|
||||
static Sprite Square()
|
||||
{
|
||||
if (square != null) return square;
|
||||
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();
|
||||
square = Sprite.Create(tex, new Rect(0, 0, 4, 4), new Vector2(0.5f, 0.5f), 4);
|
||||
return square;
|
||||
}
|
||||
|
||||
static Sprite Circle()
|
||||
{
|
||||
if (circle != null) return circle;
|
||||
const int n = 64;
|
||||
var tex = new Texture2D(n, n, TextureFormat.RGBA32, false);
|
||||
var px = new Color32[n * n];
|
||||
float r = n * 0.5f;
|
||||
for (int y = 0; y < n; y++)
|
||||
for (int x = 0; x < n; x++)
|
||||
{
|
||||
float d = Mathf.Sqrt((x + 0.5f - r) * (x + 0.5f - r) + (y + 0.5f - r) * (y + 0.5f - r));
|
||||
byte a = (byte)(Mathf.Clamp01(r - d) * 255);
|
||||
px[y * n + x] = new Color32(255, 255, 255, a);
|
||||
}
|
||||
tex.SetPixels32(px); tex.Apply();
|
||||
circle = Sprite.Create(tex, new Rect(0, 0, n, n), new Vector2(0.5f, 0.5f), n);
|
||||
return circle;
|
||||
}
|
||||
|
||||
// ---------------- Прогулка ----------------
|
||||
|
||||
Vector3 Gate()
|
||||
{
|
||||
var c = castle != null ? castle.transform.position : Vector3.zero;
|
||||
var grid = BuildGrid.Instance;
|
||||
if (grid != null && grid.FindFreeNear(c, Vector2Int.one, out Vector2Int cell))
|
||||
{
|
||||
Vector2 p = grid.RectCenter(cell, Vector2Int.one);
|
||||
return new Vector3(p.x, p.y - 0.4f, -0.5f);
|
||||
}
|
||||
return new Vector3(c.x, c.y - 1f, -0.5f);
|
||||
}
|
||||
|
||||
Vector3 PickTarget()
|
||||
{
|
||||
var grid = BuildGrid.Instance;
|
||||
if (grid == null || castle == null) return transform.position;
|
||||
grid.Refresh();
|
||||
Vector2Int home = grid.WorldToCell(castle.transform.position);
|
||||
for (int attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
int x = home.x + Random.Range(-WalkRadius, WalkRadius + 1);
|
||||
int y = home.y + Random.Range(-WalkRadius, WalkRadius + 1);
|
||||
if (grid.IsBusy(x, y)) continue;
|
||||
Vector2 p = grid.RectCenter(new Vector2Int(x, y), Vector2Int.one);
|
||||
return new Vector3(p.x, p.y - 0.4f, -0.5f);
|
||||
}
|
||||
return Gate();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
bool building = BuildModeController.Instance != null && BuildModeController.Instance.IsActive;
|
||||
if (figure.gameObject.activeSelf == building)
|
||||
{
|
||||
figure.gameObject.SetActive(!building);
|
||||
if (tapArea != null) tapArea.enabled = !building;
|
||||
}
|
||||
if (building) return;
|
||||
|
||||
if (waitLeft > 0f) { waitLeft -= Time.deltaTime; return; }
|
||||
|
||||
Vector3 pos = transform.position;
|
||||
Vector3 delta = target - pos;
|
||||
delta.z = 0f;
|
||||
if (delta.magnitude < 0.05f)
|
||||
{
|
||||
target = PickTarget();
|
||||
waitLeft = Random.Range(1f, 3f);
|
||||
return;
|
||||
}
|
||||
Vector3 step = delta.normalized * Speed * Time.deltaTime;
|
||||
if (step.magnitude > delta.magnitude) step = delta;
|
||||
transform.position = pos + step;
|
||||
if (Mathf.Abs(delta.x) > 0.01f)
|
||||
{
|
||||
var s = figure.localScale;
|
||||
s.x = delta.x < 0 ? -1f : 1f; // смотрит туда, куда идёт
|
||||
figure.localScale = s;
|
||||
}
|
||||
// лёгкое покачивание при ходьбе
|
||||
figure.localPosition = new Vector3(0f, Mathf.Abs(Mathf.Sin(Time.time * 10f)) * 0.05f, 0f);
|
||||
}
|
||||
|
||||
// ---------------- Тап ----------------
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
UIKit.Toast("Богатырь: " + Phrases[Random.Range(0, Phrases.Length)]);
|
||||
waitLeft = 2f; // остановился поговорить
|
||||
if (castle != null) BuildingWindow.Open(castle.gameObject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: caa21d91fd476684ba0fd70c830fe04f
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// ГОСПИТАЛЬ -- здание для лечения раненых (регламент 31.10; решения по 8 вопросам Саши от 31.10, п. 7).
|
||||
// - Даёт места для лечения: уровни I-V, 20-60 мест (таблица HospitalConfig, ТЕСТ).
|
||||
// - Считаются самые прокачанные госпитали: второй госпиталь мест не добавляет (регламент про несколько не говорит).
|
||||
// - Раненых хранит и лечит модуль Армии; здание только сообщает число мест через контракт IHospital (HospitalBeds).
|
||||
// - Тап открывает окно госпиталя (App/BuildingWindow): места, кто лечится и ждёт, улучшение.
|
||||
// - Уровень сохраняет модуль Поселения (ILeveledBuilding).
|
||||
// Вешается на копию обычного здания (HospitalBootstrap).
|
||||
public class Hospital : MonoBehaviour, ILeveledBuilding
|
||||
{
|
||||
public static readonly List<Hospital> All = new List<Hospital>();
|
||||
public static int Count => All.Count;
|
||||
|
||||
public int level = 1;
|
||||
public int Level { get => level; set => level = Mathf.Clamp(value, 1, MaxBuildLevel); }
|
||||
|
||||
// Уровней у госпиталя столько, сколько строк мест в таблице (регламент: I-V)
|
||||
public static int MaxBuildLevel => Mathf.Max(1, HospitalConfig.Load().bedsByLevel.Length);
|
||||
|
||||
// Уровень самого прокачанного госпиталя (0 -- госпиталя нет)
|
||||
public static int MaxLevel
|
||||
{
|
||||
get { int m = 0; foreach (var h in All) if (h != null && h.level > m) m = h.level; return m; }
|
||||
}
|
||||
|
||||
// Мест на уровне (1..5) по таблице
|
||||
public static int BedsAt(int lvl)
|
||||
{
|
||||
var beds = HospitalConfig.Load().bedsByLevel;
|
||||
if (lvl <= 0 || beds == null || beds.Length == 0) return 0;
|
||||
return Mathf.Max(0, beds[Mathf.Min(lvl, beds.Length) - 1]);
|
||||
}
|
||||
|
||||
void OnEnable() { if (!All.Contains(this)) All.Add(this); }
|
||||
void OnDisable() { All.Remove(this); }
|
||||
|
||||
Vector3 downPos;
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
// Тап (а не перетаскивание камеры) -- окно госпиталя
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
BuildingWindow.Open(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
// Места госпиталя для модуля Армии (контракт IHospital). Регистрирует App/GameModules.
|
||||
// Госпиталя нет -- 0 мест: Армия берёт HospitalConfig.temporaryBeds.
|
||||
public sealed class HospitalBeds : IHospital
|
||||
{
|
||||
public int Beds => Hospital.BedsAt(Hospital.MaxLevel);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f4c537a26bd44b3ae851af08c652a34
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,65 @@
|
||||
using UnityEngine;
|
||||
|
||||
// Добавляет «Госпиталь» в список стройки САМ при запуске сцены (как Казармы -- BarracksBootstrap).
|
||||
// Срабатывает до восстановления зданий из сохранения (SettlementRestoringEvent), поэтому построенный
|
||||
// госпиталь и его уровень корректно восстанавливаются из save.json.
|
||||
public static class HospitalBootstrap
|
||||
{
|
||||
public const string HospitalName = "Госпиталь";
|
||||
static GameObject runtimeTemplate;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Init()
|
||||
{
|
||||
Ensure();
|
||||
// при каждом новом входе в город (например, возврат с карты мира) -- снова добавить в новый BuildManager
|
||||
UnityEngine.SceneManagement.SceneManager.sceneLoaded += (s, m) => Ensure();
|
||||
}
|
||||
|
||||
public static void Ensure()
|
||||
{
|
||||
var bm = BuildManager.Instance != null ? BuildManager.Instance : Object.FindAnyObjectByType<BuildManager>();
|
||||
if (bm == null || bm.allBuildings == null) return;
|
||||
|
||||
BuildingData template = null;
|
||||
foreach (var b in bm.allBuildings)
|
||||
{
|
||||
if (b == null) continue;
|
||||
if (b.buildingName == HospitalName) return; // уже есть
|
||||
if (template == null && b.prefab != null) template = b;
|
||||
}
|
||||
if (template == null) { Debug.LogWarning("[ГОСПИТАЛЬ] Нет ни одного здания-шаблона в BuildManager"); return; }
|
||||
|
||||
// Префаб-шаблон в памяти: копия обычного здания без сбора ресурсов, с компонентом Hospital
|
||||
if (runtimeTemplate == null)
|
||||
{
|
||||
runtimeTemplate = Object.Instantiate(template.prefab);
|
||||
runtimeTemplate.name = "Hospital";
|
||||
runtimeTemplate.SetActive(false);
|
||||
Object.DontDestroyOnLoad(runtimeTemplate);
|
||||
foreach (var c in runtimeTemplate.GetComponents<ResourceCollector>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in runtimeTemplate.GetComponents<ResourceProducer>()) Object.DestroyImmediate(c);
|
||||
foreach (var c in runtimeTemplate.GetComponents<BuildingUI>()) Object.DestroyImmediate(c);
|
||||
for (int i = runtimeTemplate.transform.childCount - 1; i >= 0; i--)
|
||||
Object.DestroyImmediate(runtimeTemplate.transform.GetChild(i).gameObject); // полоска запаса ресурса
|
||||
var sr = runtimeTemplate.GetComponent<SpriteRenderer>();
|
||||
if (sr != null) sr.color = new Color(0.85f, 0.95f, 1f); // пока нет арта -- отличаем цветом (светло-голубой)
|
||||
runtimeTemplate.AddComponent<Hospital>();
|
||||
}
|
||||
|
||||
var config = HospitalConfig.Load();
|
||||
var data = ScriptableObject.CreateInstance<BuildingData>();
|
||||
data.name = "Data_Hospital_Runtime";
|
||||
data.buildingName = HospitalName;
|
||||
data.icon = template.icon;
|
||||
data.prefab = runtimeTemplate;
|
||||
data.requiredTownHallLevel = Mathf.Max(1, config.requiredTownHallLevel);
|
||||
data.cost = new[]
|
||||
{
|
||||
new ResourceCost { type = ResourceType.Wood, amount = config.buildWood },
|
||||
new ResourceCost { type = ResourceType.Stone, amount = config.buildStone },
|
||||
};
|
||||
bm.allBuildings.Add(data);
|
||||
Debug.Log("[ГОСПИТАЛЬ] Добавлен в список стройки");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3301bf2246b4444182d1362fe7bffcc5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -658,85 +658,81 @@ public class GameProgress : MonoBehaviour
|
||||
return string.Join(", ", parts.ToArray());
|
||||
}
|
||||
|
||||
// ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает
|
||||
// слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна.
|
||||
// Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши.
|
||||
|
||||
public IReadOnlyList<TrainingOrderInfo> Training => Army.Training;
|
||||
|
||||
public void Train(int index, int count)
|
||||
{
|
||||
var def = GameData.Troops[index];
|
||||
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)
|
||||
// Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением
|
||||
if (!Army.CanQueueTraining(index, count, Barracks.QueueSize, out string error)) { UIKit.Toast(error); return; }
|
||||
if (!Pay(def.cost, count)) return;
|
||||
|
||||
long durationMs = (long)(def.secondsPerUnit * 1000.0 * count);
|
||||
if (!Army.TryQueueTraining(index, count, durationMs, Barracks.QueueSize, GameClock.UnixNowMs, out error))
|
||||
{
|
||||
UIKit.Toast(population.Recruitable > 0
|
||||
? "Жителей хватит только на " + population.Recruitable + " воинов"
|
||||
: "Некого нанять: свободных жителей нет (резерв " + population.CivilianReserve + "). Снимите рабочих или стройте Дома");
|
||||
// Заказ не встал -- цену возвращаем целиком
|
||||
var refund = new RewardItem[def.cost.Length];
|
||||
for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count);
|
||||
Grant(refund, 0, false);
|
||||
UIKit.Toast(error);
|
||||
return;
|
||||
}
|
||||
if (!Pay(def.cost, count)) return;
|
||||
bool recruited = population != null && population.TryRecruit(count);
|
||||
|
||||
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
|
||||
if (start < Now) start = Now;
|
||||
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
|
||||
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited });
|
||||
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
public double TrainingSecondsLeft(TrainingOrder o) { return TimeSpan.FromTicks(Math.Max(0, o.endTicks - Now)).TotalSeconds; }
|
||||
public double TrainingSecondsLeft(TrainingOrderInfo o) { return Math.Max(0, o.EndMs - GameClock.UnixNowMs) / 1000.0; }
|
||||
|
||||
public float TrainingProgress(TrainingOrder o)
|
||||
public float TrainingProgress(TrainingOrderInfo o)
|
||||
{
|
||||
long total = o.endTicks - o.startTicks;
|
||||
long total = o.EndMs - o.StartMs;
|
||||
if (total <= 0) return 1f;
|
||||
return Mathf.Clamp01((float)(Now - o.startTicks) / total);
|
||||
return Mathf.Clamp01((float)(GameClock.UnixNowMs - o.StartMs) / total);
|
||||
}
|
||||
|
||||
public int SpeedUpCost
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Data.training.Count == 0) return 0;
|
||||
double secs = TrainingSecondsLeft(Data.training[Data.training.Count - 1]);
|
||||
var queue = Training;
|
||||
if (queue.Count == 0) return 0;
|
||||
double secs = TrainingSecondsLeft(queue[queue.Count - 1]);
|
||||
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
|
||||
}
|
||||
}
|
||||
|
||||
public void SpeedUpTraining()
|
||||
{
|
||||
if (Data.training.Count == 0) return;
|
||||
if (Training.Count == 0) return;
|
||||
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
|
||||
foreach (var o in Data.training) o.endTicks = Now;
|
||||
ProcessTraining();
|
||||
Army.FinishAllTraining(GameClock.UnixNowMs);
|
||||
}
|
||||
|
||||
// Раз в секунду: Армия заканчивает заказы обучения и лечение раненых, срок которых наступил (и пока игра была закрыта)
|
||||
void ProcessTraining()
|
||||
{
|
||||
bool any = false;
|
||||
while (Data.training.Count > 0 && Data.training[0].endTicks <= Now)
|
||||
{
|
||||
var o = Data.training[0];
|
||||
Data.training.RemoveAt(0);
|
||||
if (o.recruited && Population != null)
|
||||
{
|
||||
// Люди из Казарм переходят в Армию одним действием: не теряются и не дублируются
|
||||
if (!Population.CompleteTraining(o.troopIndex, o.count))
|
||||
{
|
||||
Debug.LogWarning($"[ВОЙСКА] Армия не приняла {o.count} × {GameData.Troops[o.troopIndex].name} -- новобранцы остаются в Казармах");
|
||||
continue; // воинов не прибавилось -- и в статистику не идёт
|
||||
}
|
||||
}
|
||||
else Army.AddTroops(o.troopIndex, o.count); // старый заказ: людей под него не брали
|
||||
Data.troopsTrained += o.count;
|
||||
Data.dailyTroops += o.count;
|
||||
Data.eventTroopRemainder += o.count;
|
||||
AddEventPoints(Data.eventTroopRemainder / 5);
|
||||
Data.eventTroopRemainder %= 5;
|
||||
UIKit.Toast("Обучено: " + o.count + " × " + GameData.Troops[o.troopIndex].name);
|
||||
any = true;
|
||||
}
|
||||
if (any) MarkChanged();
|
||||
if (!GameServices.TryGet(out IArmy army)) return;
|
||||
long now = GameClock.UnixNowMs;
|
||||
army.ProcessTraining(now);
|
||||
army.ProcessHealing(now);
|
||||
}
|
||||
|
||||
// Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка
|
||||
public void OnTroopsTrained(TroopsTrainedEvent e)
|
||||
{
|
||||
Data.troopsTrained += e.Count;
|
||||
Data.dailyTroops += e.Count;
|
||||
Data.eventTroopRemainder += e.Count;
|
||||
AddEventPoints(Data.eventTroopRemainder / 5);
|
||||
Data.eventTroopRemainder %= 5;
|
||||
if (e.Type >= 0 && e.Type < GameData.Troops.Count) UIKit.Toast("Обучено: " + e.Count + " × " + GameData.Troops[e.Type].name);
|
||||
MarkChanged();
|
||||
}
|
||||
|
||||
// Потери в бою: убираем процент от каждого типа войск
|
||||
|
||||
@@ -97,7 +97,7 @@ public class ProgressData
|
||||
// из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется.
|
||||
public int[] troops = new int[4];
|
||||
public bool armyInModule; // войска уже перенесены в модуль Армии
|
||||
public List<TrainingOrder> training = new List<TrainingOrder>();
|
||||
public List<TrainingOrder> training = new List<TrainingOrder>(); // УСТАРЕЛО: очередь у Армии (save.json, "army"); только для переноса старых сохранений
|
||||
|
||||
// --- Бой (кампания) ---
|
||||
public int battleStage = 1;
|
||||
|
||||
@@ -4,7 +4,8 @@ using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
|
||||
// ускорение за кристаллы. Новые типы войск открываются уровнем Ратуши.
|
||||
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
|
||||
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
|
||||
public class TroopsPanel : BasePanel
|
||||
{
|
||||
readonly List<Image> queueBars = new List<Image>();
|
||||
@@ -31,6 +32,20 @@ public class TroopsPanel : BasePanel
|
||||
ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") +
|
||||
" (резерв " + population.CivilianReserve.ToString("N0") + ")");
|
||||
}
|
||||
// Раненые (решения 31.10, п. 7): лечатся в госпитале или ждут места; не воюют, но считаются людьми
|
||||
IArmy hurt = GameServices.Get<IArmy>();
|
||||
if (hurt.WoundedTotal > 0)
|
||||
{
|
||||
int waiting = 0, healingNow = 0;
|
||||
long readyMs = long.MaxValue;
|
||||
for (int t = 0; t < hurt.TypeCount; t++) waiting += hurt.GetWoundedWaiting(t);
|
||||
foreach (var h in hurt.Healing) { healingNow += h.Count; readyMs = System.Math.Min(readyMs, h.EndMs); }
|
||||
string next = readyMs != long.MaxValue
|
||||
? ", ближайшие вылечатся через " + GameData.FormatTime(System.Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)
|
||||
: "";
|
||||
ProfilePanel.KV(army, "Раненые", hurt.WoundedTotal.ToString("N0") + " (лечатся " + healingNow + ", ждут места " + waiting + ")");
|
||||
ProfilePanel.KV(army, "Госпиталь", "мест " + hurt.HospitalBeds + next);
|
||||
}
|
||||
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);
|
||||
@@ -64,18 +79,19 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
// --- Очередь обучения ---
|
||||
var queue = UIKit.Card(list);
|
||||
ProfilePanel.Header(queue, "Обучение (" + d.training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count);
|
||||
if (d.training.Count == 0)
|
||||
var training = GP.Training;
|
||||
ProfilePanel.Header(queue, "Обучение (" + training.Count + " / " + Barracks.QueueSize + ") • казарм: " + Barracks.Count);
|
||||
if (training.Count == 0)
|
||||
{
|
||||
UIKit.Text(queue, "Никто не обучается. Выберите войска ниже.", 26, UIKit.Theme.TextDim);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var o in d.training)
|
||||
foreach (var o in training)
|
||||
{
|
||||
var def = GameData.Troops[o.troopIndex];
|
||||
var def = GameData.Troops[o.Type];
|
||||
var r = UIKit.Row(queue, 44, 10);
|
||||
var n = UIKit.Text(r, def.name + " × " + o.count, 28);
|
||||
var n = UIKit.Text(r, def.name + " × " + o.Count, 28);
|
||||
UIKit.Size(n, -1, -1, 1);
|
||||
var t = UIKit.Text(r, "", 26, UIKit.Theme.Gold, TextAlignmentOptions.Right);
|
||||
UIKit.Size(t, 180);
|
||||
@@ -86,37 +102,133 @@ public class TroopsPanel : BasePanel
|
||||
}
|
||||
Tick();
|
||||
|
||||
// --- Типы войск ---
|
||||
for (int i = 0; i < GameData.Troops.Count; i++)
|
||||
// --- Типы войск: карточки в ряд, листаются вбок ---
|
||||
var troopsCard = UIKit.Card(list, UIKit.Theme.CardDark);
|
||||
ProfilePanel.Header(troopsCard, "Найм войск");
|
||||
UIKit.Text(troopsCard, "Листайте карточки вбок →", 22, UIKit.Theme.TextDim);
|
||||
var strip = HorizontalStrip(troopsCard, CardHeight);
|
||||
for (int i = 0; i < GameData.Troops.Count; i++) TroopCard(strip, i);
|
||||
}
|
||||
|
||||
// ---------------- Карточка войска ----------------
|
||||
// Место под иконку: пока квадрат цвета войска с первой буквой. Картинку поставим в TroopIcon, когда будет арт.
|
||||
// "Силён / слаб против" берётся из правил боя (контры в модуле Боя и BattleConfig), а не пишется руками:
|
||||
// поменяют контры -- поменяется и текст.
|
||||
|
||||
const float CardWidth = 340f;
|
||||
const float CardHeight = 640f;
|
||||
|
||||
void TroopCard(Transform strip, int i)
|
||||
{
|
||||
int idx = i;
|
||||
var def = GameData.Troops[i];
|
||||
bool unlocked = GP.TroopUnlocked(i);
|
||||
|
||||
var card = UIKit.Card(strip, unlocked ? UIKit.Theme.Card : UIKit.Theme.CardDark, 16, 8);
|
||||
UIKit.Size(card, CardWidth, -1);
|
||||
|
||||
var iconRow = UIKit.Row(card, 130, 0);
|
||||
var lpad = UIKit.Make("Pad", iconRow); UIKit.Size(lpad, -1, -1, 1);
|
||||
TroopIcon(iconRow, i, unlocked);
|
||||
var rpad = UIKit.Make("Pad", iconRow); UIKit.Size(rpad, -1, -1, 1);
|
||||
|
||||
UIKit.Text(card, def.name, 32, UIKit.Theme.Text, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(card, "в армии: " + GP.TroopCount(i).ToString("N0"), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин", 22, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
UIKit.Text(card, GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
|
||||
string strong, weak;
|
||||
CounterText(i, out strong, out weak);
|
||||
if (strong.Length > 0) UIKit.Text(card, "<color=#9be59b>Силён против:</color> " + strong, 22, UIKit.Theme.Text);
|
||||
if (weak.Length > 0) UIKit.Text(card, "<color=#ff8a80>Слаб против:</color> " + weak, 22, UIKit.Theme.Text);
|
||||
UIKit.Text(card, def.description, 20, UIKit.Theme.TextDim);
|
||||
|
||||
var fill = UIKit.Make("Fill", card); UIKit.Size(fill, -1, -1).flexibleHeight = 1;
|
||||
|
||||
if (!unlocked)
|
||||
{
|
||||
int idx = i;
|
||||
var def = GameData.Troops[i];
|
||||
bool unlocked = GP.TroopUnlocked(i);
|
||||
|
||||
var card = UIKit.Card(list, unlocked ? UIKit.Theme.Card : UIKit.Theme.CardDark);
|
||||
var top = UIKit.Row(card, 120, 16);
|
||||
UIKit.Icon(top, unlocked ? def.color : UIKit.Theme.Gray, def.name.Substring(0, 1), 110);
|
||||
var info = UIKit.Card(top, new Color(0, 0, 0, 0), 0, 2);
|
||||
UIKit.Size(info, -1, -1, 1);
|
||||
UIKit.Text(info, def.name + " <color=#c8c8c8><size=24>в армии: " + GP.TroopCount(i).ToString("N0") + "</size></color>", 32).fontStyle = FontStyles.Bold;
|
||||
UIKit.Text(info, def.description, 22, UIKit.Theme.TextDim);
|
||||
UIKit.Text(info, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин • " + GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim);
|
||||
|
||||
if (!unlocked)
|
||||
{
|
||||
UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
continue;
|
||||
}
|
||||
|
||||
var btns = UIKit.Row(card, 80, 10);
|
||||
foreach (int amount in Amounts)
|
||||
{
|
||||
int a = amount;
|
||||
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize && GP.Recruitable >= a;
|
||||
var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 80, 30);
|
||||
UIKit.Size(b, -1, 80, 1);
|
||||
}
|
||||
UIKit.Text(card, "Откроется:\n" + GP.TroopLockText(i), 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
|
||||
return;
|
||||
}
|
||||
|
||||
var btns = UIKit.Row(card, 70, 8);
|
||||
foreach (int amount in Amounts)
|
||||
{
|
||||
int a = amount;
|
||||
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && GP.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, 70, 26);
|
||||
UIKit.Size(b, -1, 70, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Иконка войска. Сейчас -- заглушка (цветной квадрат с буквой); сюда же потом подставим спрайт.
|
||||
static void TroopIcon(Transform parent, int i, bool unlocked)
|
||||
{
|
||||
var def = GameData.Troops[i];
|
||||
UIKit.Icon(parent, unlocked ? def.color : UIKit.Theme.Gray, def.name.Substring(0, 1), 120);
|
||||
}
|
||||
|
||||
static readonly string[] TroopNamesGenitive = { "пехоты", "лучников", "кавалерии", "осадных" };
|
||||
|
||||
// Контры из правил боя: против кого этот тип сильнее и кто сильнее против него
|
||||
static void CounterText(int type, out string strong, out string weak)
|
||||
{
|
||||
var s = new List<string>();
|
||||
var w = new List<string>();
|
||||
var battle = GameServices.TryGet(out IBattleSystem b) ? b as BattleSystem : null;
|
||||
if (battle != null)
|
||||
for (int j = 0; j < TroopType.Count && j < GameData.Troops.Count; j++)
|
||||
{
|
||||
if (j == type) continue;
|
||||
int mine = battle.Counter(type, j), theirs = battle.Counter(j, type);
|
||||
if (mine > 0) s.Add(Genitive(j) + " (+" + mine + "% силы)");
|
||||
if (theirs > 0) w.Add(Genitive(j));
|
||||
}
|
||||
if (type == TroopType.Siege)
|
||||
{
|
||||
var cfg = BattleConfig.Load();
|
||||
if (cfg.siegeVsWallsPercent > 0) s.Add("стен при штурме (+" + cfg.siegeVsWallsPercent + "%)");
|
||||
if (cfg.siegeInFieldPercent < 0) w.Add("всех в поле и в обороне (" + cfg.siegeInFieldPercent + "%)");
|
||||
}
|
||||
strong = string.Join(", ", s.ToArray());
|
||||
weak = string.Join(", ", w.ToArray());
|
||||
}
|
||||
|
||||
static string Genitive(int type)
|
||||
{
|
||||
return type >= 0 && type < TroopNamesGenitive.Length ? TroopNamesGenitive[type] : GameData.Troops[type].name;
|
||||
}
|
||||
|
||||
// Горизонтальная лента с прокруткой вбок внутри общего вертикального списка.
|
||||
// Вертикальный свайп по ленте прокручивает весь список (NestedScrollRect).
|
||||
static RectTransform HorizontalStrip(Transform parent, float height)
|
||||
{
|
||||
var root = UIKit.Make("TroopStrip", parent);
|
||||
UIKit.Size(root, -1, height);
|
||||
root.gameObject.AddComponent<Image>().color = new Color(0, 0, 0, 0);
|
||||
var scroll = root.gameObject.AddComponent<NestedScrollRect>();
|
||||
scroll.horizontal = true;
|
||||
scroll.vertical = false;
|
||||
scroll.movementType = ScrollRect.MovementType.Elastic;
|
||||
scroll.scrollSensitivity = 40f;
|
||||
|
||||
var viewport = UIKit.Stretch(UIKit.Make("Viewport", root));
|
||||
viewport.gameObject.AddComponent<RectMask2D>();
|
||||
|
||||
var content = UIKit.Make("Content", viewport);
|
||||
content.anchorMin = new Vector2(0, 0); content.anchorMax = new Vector2(0, 1);
|
||||
content.pivot = new Vector2(0, 0.5f);
|
||||
content.offsetMin = Vector2.zero; content.offsetMax = Vector2.zero;
|
||||
var h = content.gameObject.AddComponent<HorizontalLayoutGroup>();
|
||||
h.spacing = 14;
|
||||
h.padding = new RectOffset(4, 4, 4, 4);
|
||||
h.childControlWidth = true; h.childControlHeight = true;
|
||||
h.childForceExpandWidth = false; h.childForceExpandHeight = true;
|
||||
content.gameObject.AddComponent<ContentSizeFitter>().horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||
|
||||
scroll.viewport = viewport;
|
||||
scroll.content = content;
|
||||
return content;
|
||||
}
|
||||
|
||||
// Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении
|
||||
@@ -130,14 +242,14 @@ public class TroopsPanel : BasePanel
|
||||
|
||||
protected override void Tick()
|
||||
{
|
||||
var d = GP.Data;
|
||||
for (int i = 0; i < queueBars.Count && i < d.training.Count; i++)
|
||||
var training = GP.Training;
|
||||
for (int i = 0; i < queueBars.Count && i < training.Count; i++)
|
||||
{
|
||||
var o = d.training[i];
|
||||
var o = training[i];
|
||||
UIKit.SetBar(queueBars[i], GP.TrainingProgress(o));
|
||||
queueTimes[i].text = GameData.FormatTime(GP.TrainingSecondsLeft(o));
|
||||
}
|
||||
if (speedUp != null && d.training.Count > 0)
|
||||
if (speedUp != null && training.Count > 0)
|
||||
UIKit.SetButton(speedUp, "Ускорить всё за " + GP.SpeedUpCost + " кристаллов", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Прокрутка внутри прокрутки (например, лента карточек вбок внутри вертикального списка).
|
||||
// Свайп в сторону своей оси листает эту ленту, свайп поперёк и колесо мыши -- родительский список.
|
||||
// Без этого палец на ленте "застревает": общий список не прокручивается.
|
||||
public class NestedScrollRect : ScrollRect
|
||||
{
|
||||
ScrollRect parentScroll;
|
||||
bool routeToParent;
|
||||
|
||||
protected override void Start()
|
||||
{
|
||||
base.Start();
|
||||
if (transform.parent != null) parentScroll = transform.parent.GetComponentInParent<ScrollRect>();
|
||||
}
|
||||
|
||||
bool CrossAxis(PointerEventData e)
|
||||
{
|
||||
float dx = Mathf.Abs(e.delta.x), dy = Mathf.Abs(e.delta.y);
|
||||
return horizontal && !vertical ? dy > dx : vertical && !horizontal ? dx > dy : false;
|
||||
}
|
||||
|
||||
public override void OnInitializePotentialDrag(PointerEventData e)
|
||||
{
|
||||
if (parentScroll != null) parentScroll.OnInitializePotentialDrag(e);
|
||||
base.OnInitializePotentialDrag(e);
|
||||
}
|
||||
|
||||
public override void OnBeginDrag(PointerEventData e)
|
||||
{
|
||||
routeToParent = parentScroll != null && CrossAxis(e);
|
||||
if (routeToParent) parentScroll.OnBeginDrag(e);
|
||||
else base.OnBeginDrag(e);
|
||||
}
|
||||
|
||||
public override void OnDrag(PointerEventData e)
|
||||
{
|
||||
if (routeToParent) parentScroll.OnDrag(e);
|
||||
else base.OnDrag(e);
|
||||
}
|
||||
|
||||
public override void OnEndDrag(PointerEventData e)
|
||||
{
|
||||
if (routeToParent) parentScroll.OnEndDrag(e);
|
||||
else base.OnEndDrag(e);
|
||||
routeToParent = false;
|
||||
}
|
||||
|
||||
public override void OnScroll(PointerEventData e)
|
||||
{
|
||||
if (parentScroll != null) parentScroll.OnScroll(e);
|
||||
else base.OnScroll(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59a54e4603712204d81f167a1c02873f
|
||||
@@ -9,6 +9,26 @@ using System.Text;
|
||||
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
|
||||
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
|
||||
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
|
||||
// Очередь обучения (training) -- тоже здесь (решения по 8 вопросам Саши, 31.10, п. 8): заказы, сроки, завершение.
|
||||
// Раненые (решения 31.10, п. 7): ждут места (woundedWaiting) или лечатся (healing) -- не в troops и не в походах.
|
||||
// Числа госпиталя (регламент 31.10, ТЕСТ). В игре -- из таблицы HospitalConfig.
|
||||
public sealed class HospitalRules
|
||||
{
|
||||
public int TemporaryBeds = 5; // мест, пока здания госпиталя нет (решения 01.11, п. 3); после постройки -- штатные, не вместе
|
||||
public int[] HealGameHours = { 4, 4, 6, 8 }; // лечение одного воина: пехота и лучники 4, кавалерия 6, осадные 8 ИГРОВЫХ часов
|
||||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||||
// Цена лечения одного воина по типам (решения 01.11, п. 3: 1 / 1 / 2 / 3 золота -- в таблице HospitalConfig); пусто -- без цены
|
||||
public ResourceAmount[][] HealCostPerSoldier = null;
|
||||
|
||||
public static HospitalRules Default => new HospitalRules();
|
||||
|
||||
public long HealMs(int type)
|
||||
{
|
||||
int hours = HealGameHours != null && type >= 0 && type < HealGameHours.Length ? Math.Max(0, HealGameHours[type]) : 4;
|
||||
return hours * GameHourMs;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
|
||||
@@ -23,6 +43,29 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
private readonly int[] garrison = new int[Types];
|
||||
private readonly List<MarchUnits> marches = new List<MarchUnits>();
|
||||
|
||||
private class TrainingOrder
|
||||
{
|
||||
public int id, type, count;
|
||||
public long startMs, endMs;
|
||||
public bool recruited; // под заказ взяты жители (Население). false -- старый заказ, людей не брали
|
||||
}
|
||||
|
||||
private class HealingBatch
|
||||
{
|
||||
public int id, type, count;
|
||||
public long startMs, endMs;
|
||||
}
|
||||
|
||||
private readonly int[] woundedWaiting = new int[Types];
|
||||
private readonly List<HealingBatch> healing = new List<HealingBatch>();
|
||||
private readonly List<HealingInfo> healingView = new List<HealingInfo>();
|
||||
private int nextHealId = 1;
|
||||
private readonly HospitalRules hospital;
|
||||
|
||||
private readonly List<TrainingOrder> training = new List<TrainingOrder>();
|
||||
private readonly List<TrainingOrderInfo> trainingView = new List<TrainingOrderInfo>();
|
||||
private int nextOrderId = 1;
|
||||
|
||||
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
|
||||
public bool RestoredFromSave { get; private set; }
|
||||
|
||||
@@ -30,6 +73,20 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
public int TypeCount => Types;
|
||||
|
||||
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
|
||||
private readonly int baseMarchSlots;
|
||||
|
||||
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null)
|
||||
{
|
||||
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
|
||||
this.hospital = hospital ?? HospitalRules.Default;
|
||||
}
|
||||
|
||||
public HospitalRules Hospital => hospital;
|
||||
|
||||
public int MarchSlots => baseMarchSlots;
|
||||
public int ActiveMarches => marches.Count;
|
||||
|
||||
public int GetTroops(int type)
|
||||
{
|
||||
return type >= 0 && type < Types ? troops[type] : 0;
|
||||
@@ -100,6 +157,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
{
|
||||
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
|
||||
if (marches.Exists(m => m.marchId == marchId)) return false;
|
||||
// Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет
|
||||
if (marches.Count >= MarchSlots) return false;
|
||||
|
||||
// В поход -- только свободные воины, гарнизон остаётся в городе
|
||||
var free = new int[Types];
|
||||
@@ -139,17 +198,217 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
|
||||
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
|
||||
public bool ReturnUnits(int marchId, int[] survivorUnits)
|
||||
{
|
||||
return ReturnUnits(marchId, survivorUnits, null, 0);
|
||||
}
|
||||
|
||||
// Выжившие -- в город, раненые -- в госпиталь (решения 31.10, п. 7). Остальные из ушедших погибли.
|
||||
// Один солдат в одном месте: выжившие + раненые не больше, чем ушло, по каждому типу.
|
||||
public bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs)
|
||||
{
|
||||
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]));
|
||||
bool anyWounded = false;
|
||||
if (woundedUnits != null) ProcessHealing(nowMs); // сначала освободить места, которые освободились раньше
|
||||
for (int i = 0; i < Types; i++)
|
||||
{
|
||||
int alive = survivorUnits != null && i < survivorUnits.Length ? Math.Max(0, Math.Min(survivorUnits[i], entry.units[i])) : 0;
|
||||
int hurt = woundedUnits != null && i < woundedUnits.Length ? Math.Max(0, Math.Min(woundedUnits[i], entry.units[i] - alive)) : 0;
|
||||
troops[i] += alive;
|
||||
woundedWaiting[i] += hurt;
|
||||
if (hurt > 0) anyWounded = true;
|
||||
}
|
||||
marches.Remove(entry);
|
||||
if (anyWounded) FillBeds(nowMs);
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------- Раненые и лечение ----------------
|
||||
|
||||
public int WoundedTotal
|
||||
{
|
||||
get
|
||||
{
|
||||
int n = 0;
|
||||
foreach (var w in woundedWaiting) n += w;
|
||||
foreach (var b in healing) n += b.count;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public int GetWoundedWaiting(int type)
|
||||
{
|
||||
return type >= 0 && type < Types ? woundedWaiting[type] : 0;
|
||||
}
|
||||
|
||||
public IReadOnlyList<HealingInfo> Healing
|
||||
{
|
||||
get
|
||||
{
|
||||
healingView.Clear();
|
||||
foreach (var b in healing)
|
||||
healingView.Add(new HealingInfo { Id = b.id, Type = b.type, Count = b.count, StartMs = b.startMs, EndMs = b.endMs });
|
||||
return healingView;
|
||||
}
|
||||
}
|
||||
|
||||
// Места: от здания госпиталя (Поселение), пока его нет -- временные из таблицы
|
||||
public int HospitalBeds => GameServices.TryGet(out IHospital h) && h.Beds > 0 ? h.Beds : Math.Max(0, hospital.TemporaryBeds);
|
||||
|
||||
public int ProcessHealing(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
// По времени: самая ранняя группа заканчивает -- на её место в тот же момент ложатся ждущие
|
||||
while (true)
|
||||
{
|
||||
HealingBatch next = null;
|
||||
foreach (var b in healing)
|
||||
if (b.endMs <= nowMs && (next == null || b.endMs < next.endMs || b.endMs == next.endMs && b.id < next.id)) next = b;
|
||||
if (next == null) break;
|
||||
|
||||
healing.Remove(next); // группа вылечена один раз: повторный расчёт её уже не найдёт
|
||||
troops[next.type] += next.count;
|
||||
done++;
|
||||
EventBus.Publish(new SoldiersHealedEvent { BatchId = next.id, Type = next.type, Count = next.count, TimeMs = next.endMs });
|
||||
FillBeds(next.endMs);
|
||||
}
|
||||
FillBeds(nowMs); // мест могло стать больше (госпиталь улучшили)
|
||||
if (done > 0) Changed();
|
||||
return done;
|
||||
}
|
||||
|
||||
// Положить ждущих на свободные места в момент atMs: по типам по порядку, каждая группа -- свой срок лечения.
|
||||
// Цена лечения (если задана) списывается через Экономику целиком; не хватает -- группа ждёт.
|
||||
void FillBeds(long atMs)
|
||||
{
|
||||
int free = HospitalBeds;
|
||||
foreach (var b in healing) free -= b.count;
|
||||
for (int type = 0; type < Types && free > 0; type++)
|
||||
{
|
||||
int n = Math.Min(free, woundedWaiting[type]);
|
||||
if (n <= 0) continue;
|
||||
if (!PayForHealing(type, n)) continue;
|
||||
woundedWaiting[type] -= n;
|
||||
free -= n;
|
||||
healing.Add(new HealingBatch { id = nextHealId++, type = type, count = n, startMs = atMs, endMs = atMs + hospital.HealMs(type) });
|
||||
}
|
||||
}
|
||||
|
||||
bool PayForHealing(int type, int soldiers)
|
||||
{
|
||||
var perSoldier = hospital.HealCostPerSoldier != null && type < hospital.HealCostPerSoldier.Length ? hospital.HealCostPerSoldier[type] : null;
|
||||
if (perSoldier == null || perSoldier.Length == 0) return true; // цена не утверждена -- без цены
|
||||
if (!GameServices.TryGet(out IEconomy economy)) return false;
|
||||
var cost = new List<ResourceAmount>();
|
||||
foreach (var c in perSoldier) if (c.Amount > 0) cost.Add(new ResourceAmount(c.Type, c.Amount * soldiers));
|
||||
return cost.Count == 0 || economy.TrySpend(cost);
|
||||
}
|
||||
|
||||
// ---------------- Очередь обучения ----------------
|
||||
|
||||
public IReadOnlyList<TrainingOrderInfo> Training
|
||||
{
|
||||
get
|
||||
{
|
||||
trainingView.Clear();
|
||||
foreach (var o in training)
|
||||
trainingView.Add(new TrainingOrderInfo { Id = o.id, Type = o.type, Count = o.count, StartMs = o.startMs, EndMs = o.endMs });
|
||||
return trainingView;
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanQueueTraining(int type, int count, int queueSize, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (type < 0 || type >= Types || count <= 0) error = "Неверный заказ";
|
||||
else if (training.Count >= queueSize) error = "Очередь обучения заполнена. Больше казарм -- длиннее очередь";
|
||||
else if (GameServices.TryGet(out IPopulation population) && population.Recruitable < count)
|
||||
error = population.Recruitable > 0
|
||||
? $"Жителей хватит только на {population.Recruitable} воинов"
|
||||
: $"Некого нанять: свободных жителей нет (резерв {population.CivilianReserve}). Снимите рабочих или стройте Дома";
|
||||
return error == null;
|
||||
}
|
||||
|
||||
public bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error)
|
||||
{
|
||||
if (!CanQueueTraining(type, count, queueSize, out error)) return false;
|
||||
|
||||
// Воин -- житель города: люди уходят в Казармы сейчас, в Армию -- когда обучатся
|
||||
bool recruited = false;
|
||||
if (GameServices.TryGet(out IPopulation population))
|
||||
{
|
||||
if (!population.TryRecruit(count)) { error = "Некого нанять: жителей не хватает"; return false; }
|
||||
recruited = true;
|
||||
}
|
||||
|
||||
// Заказы идут друг за другом: новый начинается, когда закончится последний
|
||||
long start = training.Count > 0 ? Math.Max(nowMs, training[training.Count - 1].endMs) : nowMs;
|
||||
training.Add(new TrainingOrder
|
||||
{
|
||||
id = nextOrderId++, type = type, count = count,
|
||||
startMs = start, endMs = start + Math.Max(0, durationMs), recruited = recruited
|
||||
});
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public int ProcessTraining(long nowMs)
|
||||
{
|
||||
int done = 0;
|
||||
while (training.Count > 0 && training[0].endMs <= nowMs)
|
||||
{
|
||||
TrainingOrder o = training[0];
|
||||
if (o.recruited)
|
||||
{
|
||||
// Новобранцы переходят из Населения в Армию одним действием (Население само зовёт AddTroops).
|
||||
// Населения ещё нет (запуск) -- подождём: заказ остаётся, никто не теряется.
|
||||
if (!GameServices.TryGet(out IPopulation population)) break;
|
||||
if (!population.CompleteTraining(o.type, o.count))
|
||||
{
|
||||
// Население не нашло этих новобранцев (испорченное сохранение): заказ снимается без воинов --
|
||||
// иначе люди появились бы из ниоткуда, а очередь встала бы навсегда
|
||||
training.RemoveAt(0);
|
||||
Changed();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else AddTroops(o.type, o.count); // старый заказ: людей под него не брали
|
||||
|
||||
training.RemoveAt(0); // заказ закончен один раз: повторный расчёт его уже не найдёт
|
||||
done++;
|
||||
EventBus.Publish(new TroopsTrainedEvent { OrderId = o.id, Type = o.type, Count = o.count, TimeMs = o.endMs });
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
public int FinishAllTraining(long nowMs)
|
||||
{
|
||||
foreach (var o in training)
|
||||
{
|
||||
o.endMs = Math.Min(o.endMs, nowMs);
|
||||
o.startMs = Math.Min(o.startMs, o.endMs);
|
||||
}
|
||||
return ProcessTraining(nowMs);
|
||||
}
|
||||
|
||||
// Перенос очереди из старого progress.json (её вёл GameProgress). Время уже переведено в мс часов игры.
|
||||
public void ImportLegacyTraining(IEnumerable<(int type, int count, long startMs, long endMs, bool recruited)> orders)
|
||||
{
|
||||
if (orders == null) return;
|
||||
foreach (var o in orders)
|
||||
{
|
||||
if (o.type < 0 || o.type >= Types || o.count <= 0) continue;
|
||||
training.Add(new TrainingOrder
|
||||
{
|
||||
id = nextOrderId++, type = o.type, count = o.count,
|
||||
startMs = o.startMs, endMs = Math.Max(o.startMs, o.endMs), recruited = o.recruited
|
||||
});
|
||||
}
|
||||
Changed();
|
||||
}
|
||||
|
||||
void ClampGarrison()
|
||||
{
|
||||
for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i]));
|
||||
@@ -157,7 +416,7 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
|
||||
void Changed()
|
||||
{
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal });
|
||||
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal });
|
||||
}
|
||||
|
||||
// ---------------- Перенос из старого progress.json ----------------
|
||||
@@ -189,6 +448,11 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
|
||||
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
// train=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
|
||||
// trainNext=<номер следующего заказа>
|
||||
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
|
||||
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
|
||||
// healNext=<номер следующей группы лечения>
|
||||
|
||||
public string SaveKey => "army";
|
||||
|
||||
@@ -199,6 +463,20 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
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');
|
||||
foreach (var o in training)
|
||||
sb.Append("train=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(o.id), N(o.type), N(o.count),
|
||||
o.startMs.ToString(CultureInfo.InvariantCulture), o.endMs.ToString(CultureInfo.InvariantCulture), o.recruited ? "1" : "0"
|
||||
})).Append('\n');
|
||||
sb.Append("trainNext=").Append(N(nextOrderId)).Append('\n');
|
||||
sb.Append("wounded=").Append(Join(woundedWaiting)).Append('\n');
|
||||
foreach (var b in healing)
|
||||
sb.Append("heal=").Append(string.Join("|", new[]
|
||||
{
|
||||
N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture)
|
||||
})).Append('\n');
|
||||
sb.Append("healNext=").Append(N(nextHealId)).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -207,6 +485,11 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
training.Clear();
|
||||
nextOrderId = 1;
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
RestoredFromSave = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(data))
|
||||
@@ -231,6 +514,45 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
|
||||
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
|
||||
}
|
||||
else if (line.StartsWith("train="))
|
||||
{
|
||||
string[] p = line.Substring("train=".Length).Split('|');
|
||||
if (p.Length == 6 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int oid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int type) && type >= 0 && type < Types
|
||||
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) && count > 0
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long start)
|
||||
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long end)
|
||||
&& !training.Exists(x => x.id == oid)) // один заказ -- одна запись
|
||||
{
|
||||
training.Add(new TrainingOrder { id = oid, type = type, count = count, startMs = start, endMs = end, recruited = p[5] == "1" });
|
||||
nextOrderId = Math.Max(nextOrderId, oid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("wounded="))
|
||||
{
|
||||
int[] w = Parse(line.Substring("wounded=".Length));
|
||||
Array.Copy(w, woundedWaiting, Types);
|
||||
}
|
||||
else if (line.StartsWith("heal="))
|
||||
{
|
||||
string[] p = line.Substring("heal=".Length).Split('|');
|
||||
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hid)
|
||||
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int htype) && htype >= 0 && htype < Types
|
||||
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hcount) && hcount > 0
|
||||
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hstart)
|
||||
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hend)
|
||||
&& !healing.Exists(x => x.id == hid))
|
||||
{
|
||||
healing.Add(new HealingBatch { id = hid, type = htype, count = hcount, startMs = hstart, endMs = hend });
|
||||
nextHealId = Math.Max(nextHealId, hid + 1);
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("healNext=")
|
||||
&& int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext))
|
||||
nextHealId = Math.Max(nextHealId, hnext);
|
||||
else if (line.StartsWith("trainNext=")
|
||||
&& int.TryParse(line.Substring("trainNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int next))
|
||||
nextOrderId = Math.Max(nextOrderId, next);
|
||||
}
|
||||
}
|
||||
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
|
||||
@@ -243,9 +565,16 @@ public sealed class ArmyService : IArmy, ISaveParticipant
|
||||
Array.Clear(troops, 0, Types);
|
||||
Array.Clear(garrison, 0, Types);
|
||||
marches.Clear();
|
||||
training.Clear();
|
||||
nextOrderId = 1;
|
||||
Array.Clear(woundedWaiting, 0, Types);
|
||||
healing.Clear();
|
||||
nextHealId = 1;
|
||||
Changed();
|
||||
}
|
||||
|
||||
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
static string Join(int[] values)
|
||||
{
|
||||
var parts = new string[values.Length];
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// итог боя запоминает версию, по которой посчитан.
|
||||
public sealed class BattleRules
|
||||
{
|
||||
public int Version = 2; // 2 -- добавлены голод и усталость
|
||||
public int Version = 7; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
|
||||
// 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7);
|
||||
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1)
|
||||
|
||||
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
|
||||
public int[] UnitPower = { 4, 5, 9, 16 };
|
||||
@@ -15,9 +17,26 @@ public sealed class BattleRules
|
||||
public int WallsPercent = 20; // защитникам за стенами
|
||||
public int SiegeVsWallsPercent = 50; // осадным при штурме стен
|
||||
public int SiegeInFieldPercent = -25; // осадным в поле и в обороне
|
||||
public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула)
|
||||
public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5%
|
||||
|
||||
// Кривая потерь (решение №14, регламент 31.10, ТЕСТ): доля выбывших от каждого типа в зависимости от
|
||||
// соотношения итоговых сил (сильная / слабая), в десятых долях процента. Между опорными точками -- плавно
|
||||
// (линейно), без порогов-переключателей.
|
||||
// Разгром (решения 31.10, п. 5): 7:1 -- 92%, 10:1 -- 95%, дальше слабая сторона плавно стремится к WeakLossMaxPermille (99%):
|
||||
// v = max - (max - v_last) * R_last / R -- при 20:1 97%, при 100:1 98,6%; 100% не достигается.
|
||||
// Сильная сторона (решения по отчёту Саши, 01.11, п. 1): 5:1 -- 11%, плавно до 2% при 10:1, дальше 2%.
|
||||
public int[] LossRatioPermille = { 1000, 3000, 5000, 7000, 10000 }; // 1:1, 3:1, 5:1, 7:1, 10:1
|
||||
public int[] StrongLossPermille = { 450, 160, 110, 74, 20 }; // сильная сторона: 45%, 16%, 11%, 7,4%, 2%
|
||||
public int[] WeakLossPermille = { 450, 740, 880, 920, 950 }; // слабая сторона: 45%, 74%, 88%, 92%, 95%
|
||||
public int WeakLossMaxPermille = 990; // верхний предел ожидаемых потерь слабой стороны: 99%
|
||||
|
||||
// Погибшие и раненые (регламент 31.10, ТЕСТ; решения 31.10, п. 7): из выбывших стороны погибает доля,
|
||||
// зависящая от её соотношения сил (своя / чужая), остальные -- раненые. Ориентиры регламента:
|
||||
// разгром 1:5 -- ~70%, поражение 1:2 -- ~45%, равный бой -- ~30%, уверенная победа 3:1 -- ~20%.
|
||||
// Между точками -- плавно (линейно), за крайними -- значения крайних. Округление -- отдельным броском.
|
||||
public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1
|
||||
public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20%
|
||||
|
||||
// Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40%
|
||||
public int FamineStrengthPercent = 50; // голод: -50%
|
||||
public int FatigueStrengthPercent = 80; // усталость: -20%
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
|
||||
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
|
||||
//
|
||||
// Правила (стартовые числа -- в BattleRules, утверждает команда):
|
||||
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
|
||||
// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026):
|
||||
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус)
|
||||
// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача);
|
||||
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
|
||||
// бонус типа против врага взвешивается по составу врага;
|
||||
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
|
||||
// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог;
|
||||
// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12);
|
||||
// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12);
|
||||
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
|
||||
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
|
||||
// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие.
|
||||
// - потери (№14): доля выбывших каждого типа -- плавная функция соотношения итоговых сил (сильная / слабая)
|
||||
// по опорным точкам BattleRules: 1:1 -- 45% / 45%, 3:1 -- 16% / 74%, 5:1 -- 11% / 88%. Одинаково для PvE, PvP и Круга;
|
||||
// - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери,
|
||||
// из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие.
|
||||
public sealed class BattleSystem : IBattleSystem
|
||||
{
|
||||
// Точная неотрицательная дробь: сила x100 = N / D
|
||||
struct Fraction
|
||||
{
|
||||
public BigInteger N, D;
|
||||
public long Floor => (long)(N / D);
|
||||
}
|
||||
|
||||
enum Rounding { Roll, Floor, Ceiling } // бой -- бросок по №13; прогноз -- лучший и худший случай
|
||||
|
||||
private readonly BattleRules rules;
|
||||
private readonly int[] power;
|
||||
private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил)
|
||||
private readonly int[] deadRatio, deadShare; // доля погибших среди выбывших (проверенная копия)
|
||||
|
||||
public BattleRules Rules => rules;
|
||||
|
||||
@@ -29,6 +45,71 @@ public sealed class BattleSystem : IBattleSystem
|
||||
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];
|
||||
|
||||
// Кривая из таблицы, если она корректна, иначе -- значения по умолчанию (ошибка в Inspector не ломает бой)
|
||||
var r = this.rules;
|
||||
bool ok = r.LossRatioPermille != null && r.StrongLossPermille != null && r.WeakLossPermille != null
|
||||
&& r.LossRatioPermille.Length > 0
|
||||
&& r.LossRatioPermille.Length == r.StrongLossPermille.Length
|
||||
&& r.LossRatioPermille.Length == r.WeakLossPermille.Length
|
||||
&& r.LossRatioPermille[0] == 1000;
|
||||
for (int i = 0; ok && i < r.LossRatioPermille.Length; i++)
|
||||
{
|
||||
ok = r.StrongLossPermille[i] >= 0 && r.StrongLossPermille[i] <= 1000 && r.WeakLossPermille[i] >= 0 && r.WeakLossPermille[i] <= 1000
|
||||
&& (i == 0 || r.LossRatioPermille[i] > r.LossRatioPermille[i - 1]);
|
||||
}
|
||||
var src = ok ? r : BattleRules.Default;
|
||||
lossRatio = (int[])src.LossRatioPermille.Clone();
|
||||
strongLoss = (int[])src.StrongLossPermille.Clone();
|
||||
weakLoss = (int[])src.WeakLossPermille.Clone();
|
||||
// Доля погибших: та же проверка -- ошибка в таблице не ломает бой
|
||||
bool deadOk = r.DeadRatioPermille != null && r.DeadSharePermille != null && r.DeadRatioPermille.Length > 0
|
||||
&& r.DeadRatioPermille.Length == r.DeadSharePermille.Length;
|
||||
for (int i = 0; deadOk && i < r.DeadRatioPermille.Length; i++)
|
||||
deadOk = r.DeadRatioPermille[i] > 0 && r.DeadSharePermille[i] >= 0 && r.DeadSharePermille[i] <= 1000
|
||||
&& (i == 0 || r.DeadRatioPermille[i] > r.DeadRatioPermille[i - 1]);
|
||||
var deadSrc = deadOk ? r : BattleRules.Default;
|
||||
deadRatio = (int[])deadSrc.DeadRatioPermille.Clone();
|
||||
deadShare = (int[])deadSrc.DeadSharePermille.Clone();
|
||||
|
||||
// Предел не ниже последней точки и не выше 100%
|
||||
weakLossMax = Math.Min(1000, Math.Max(weakLoss[weakLoss.Length - 1], r.WeakLossMaxPermille));
|
||||
}
|
||||
|
||||
private int weakLossMax;
|
||||
|
||||
// Доля выбывших стороны (в десятых долях процента, дробью) при соотношении сил stronger / weaker (решение №14)
|
||||
void LossShare(Fraction weaker, Fraction stronger, bool isWeak, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
int[] values = isWeak ? weakLoss : strongLoss;
|
||||
int last = lossRatio.Length - 1;
|
||||
if (weaker.N.IsZero)
|
||||
{
|
||||
// У слабой стороны нулевая сила: сильная не теряет никого, слабая -- предел разгрома
|
||||
num = isWeak ? weakLossMax : 0;
|
||||
den = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Соотношение сил в десятых долях процента: R = 1000 * stronger / weaker = rn / rd
|
||||
BigInteger rn = 1000 * stronger.N * weaker.D;
|
||||
BigInteger rd = stronger.D * weaker.N;
|
||||
if (rn <= lossRatio[0] * rd) { num = values[0]; den = 1; return; }
|
||||
if (rn >= lossRatio[last] * rd)
|
||||
{
|
||||
if (!isWeak) { num = values[last]; den = 1; return; }
|
||||
// Дальше последней точки слабая сторона плавно стремится к пределу: v = max - (max - v_last) * R_last / R
|
||||
num = (BigInteger)weakLossMax * rn - (BigInteger)(weakLossMax - values[last]) * lossRatio[last] * rd;
|
||||
den = rn;
|
||||
return;
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
while (rn >= lossRatio[i + 1] * rd) i++; // lossRatio[i] <= R < lossRatio[i + 1]
|
||||
long span = lossRatio[i + 1] - lossRatio[i];
|
||||
// v = v_i + (v_{i+1} - v_i) * (R - p_i) / span -- точной дробью
|
||||
num = (BigInteger)values[i] * span * rd + (BigInteger)(values[i + 1] - values[i]) * (rn - lossRatio[i] * rd);
|
||||
den = span * rd;
|
||||
}
|
||||
|
||||
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
|
||||
@@ -42,57 +123,74 @@ public sealed class BattleSystem : IBattleSystem
|
||||
|
||||
public BattleOutcome Resolve(BattleRequest request)
|
||||
{
|
||||
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2));
|
||||
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2), Rounding.Roll);
|
||||
}
|
||||
|
||||
// Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается.
|
||||
// Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается.
|
||||
public BattleForecast Forecast(BattleRequest request)
|
||||
{
|
||||
int r = rules.RandomPercent * 10;
|
||||
int r = Math.Max(0, 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);
|
||||
BattleOutcome best = Fight(request, attLuck, defLuck, Rounding.Floor);
|
||||
BattleOutcome worst = Fight(request, attLuck, defLuck, Rounding.Ceiling);
|
||||
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, best.AttackerPower);
|
||||
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, best.AttackerPower);
|
||||
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, best.DefenderPower);
|
||||
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, best.DefenderPower);
|
||||
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, best.AttackerLosses);
|
||||
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, worst.AttackerLosses);
|
||||
}
|
||||
f.Chance = !anyLoss ? BattleChance.Advantage : !anyWin ? BattleChance.HighRisk : BattleChance.Uncertain;
|
||||
|
||||
// Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее
|
||||
Sides(request, out Fraction att, out Fraction def, out _, out _, out _, out _);
|
||||
BigInteger x = att.N * def.D, y = def.N * att.D; // нападающий побеждает, если x * (1000 + a) > y * (1000 + d)
|
||||
long wins = 0, total = (long)(2 * r + 1) * (2 * r + 1);
|
||||
for (int a = -r; a <= r; a++)
|
||||
{
|
||||
BigInteger t = x * (1000 + a);
|
||||
if (y.IsZero) { if (t > 0) wins += 2 * r + 1; continue; }
|
||||
// нужно y * (1000 + d) < t, то есть 1000 + d <= ceil(t / y) - 1
|
||||
BigInteger dMax = (t + y - 1) / y - 1 - 1000;
|
||||
if (dMax < -r) continue;
|
||||
wins += dMax >= r ? 2 * r + 1 : (long)dMax + r + 1;
|
||||
}
|
||||
f.WinChancePermille = (int)((wins * 1000 + total / 2) / total);
|
||||
f.Chance = wins == total ? BattleChance.Advantage : wins == 0 ? BattleChance.HighRisk : BattleChance.Uncertain;
|
||||
return f;
|
||||
}
|
||||
|
||||
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille)
|
||||
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille, Rounding rounding)
|
||||
{
|
||||
int[] att = Units(request.AttackerUnits, request.Attackers);
|
||||
int[] def = Units(request.DefenderUnits, request.Defenders);
|
||||
Sides(request, out Fraction attBase, out Fraction defBase, out int[] att, out int[] def,
|
||||
out int attCondition, out int defCondition);
|
||||
Fraction attPower = WithLuck(attBase, attLuckPermille);
|
||||
Fraction defPower = WithLuck(defBase, defLuckPermille);
|
||||
|
||||
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.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника
|
||||
|
||||
bool victory = attPower > defPower; // ничья -- в пользу защитника
|
||||
// Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1
|
||||
Fraction weaker = victory ? defPower : attPower;
|
||||
Fraction stronger = victory ? attPower : defPower;
|
||||
int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding);
|
||||
int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding);
|
||||
|
||||
// Потери в процентах: проигравший -- 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)
|
||||
// Выбывшие -- погибшие и раненые (решения 31.10, п. 7): доля погибших -- от соотношения сил своей стороны
|
||||
int[] attDead = Dead(attOut, attPower, defPower, request.Seed, side: 1, rounding);
|
||||
int[] defDead = Dead(defOut0, defPower, attPower, request.Seed, side: 2, rounding);
|
||||
int[] attWounded = new int[TroopType.Count];
|
||||
int[] defWounded = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
attWounded[i] = attOut[i] - attDead[i];
|
||||
defWounded[i] = defOut0[i] - defDead[i];
|
||||
}
|
||||
|
||||
var attAlive = new int[TroopType.Count];
|
||||
var defAlive = new int[TroopType.Count];
|
||||
@@ -112,8 +210,8 @@ public sealed class BattleSystem : IBattleSystem
|
||||
AttackerLosses = Sum(attDead) + Sum(attWounded),
|
||||
DefenderLosses = Sum(defOut),
|
||||
Survivors = Sum(attAlive),
|
||||
AttackerPower = attPower,
|
||||
DefenderPower = defPower,
|
||||
AttackerPower = attPower.Floor, // округление -- только для показа
|
||||
DefenderPower = defPower.Floor,
|
||||
AttackerLuckPermille = attLuckPermille,
|
||||
DefenderLuckPermille = defLuckPermille,
|
||||
AttackerConditionPercent = attCondition,
|
||||
@@ -130,69 +228,153 @@ public sealed class BattleSystem : IBattleSystem
|
||||
};
|
||||
}
|
||||
|
||||
// Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 40%
|
||||
public int ConditionPercent(BattleCondition conditions)
|
||||
// Силы сторон без удачи (точные дроби) и состав сторон
|
||||
void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def,
|
||||
out int attCondition, out int defCondition)
|
||||
{
|
||||
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);
|
||||
att = Units(request.AttackerUnits, request.Attackers);
|
||||
def = Units(request.DefenderUnits, request.Defenders);
|
||||
attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent,
|
||||
request.AttackerConditions, out attCondition);
|
||||
defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
|
||||
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0),
|
||||
request.DefenderConditions, out defCondition);
|
||||
}
|
||||
|
||||
// Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах.
|
||||
// Порядок: контры и осадные -> бонусы стороны -> состояния (голод, усталость) -> случайность
|
||||
// (порядок бонусов и случайности -- открытый вопрос плана).
|
||||
long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int conditionPercent, int luckPermille)
|
||||
// Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 40%
|
||||
public int ConditionPercent(BattleCondition conditions)
|
||||
{
|
||||
ConditionFactor(conditions, out BigInteger num, out BigInteger den);
|
||||
return (int)(num * 100 / den);
|
||||
}
|
||||
|
||||
void ConditionFactor(BattleCondition conditions, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
num = 1; den = 1;
|
||||
if ((conditions & BattleCondition.Famine) != 0) { num *= Math.Max(0, rules.FamineStrengthPercent); den *= 100; }
|
||||
if ((conditions & BattleCondition.Fatigue) != 0) { num *= Math.Max(0, rules.FatigueStrengthPercent); den *= 100; }
|
||||
}
|
||||
|
||||
// Сила стороны x100 точной дробью. Порядок: контры и осадные -> бонусы стороны -> состояния; удача -- в WithLuck.
|
||||
// Контра по составу врага (counterSum / enemyTotal) остаётся дробью -- без промежуточного округления (№12).
|
||||
Fraction SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent,
|
||||
BattleCondition conditions, out int conditionPercent)
|
||||
{
|
||||
int enemyTotal = Sum(enemy);
|
||||
long total = 0;
|
||||
int e = Math.Max(1, enemyTotal);
|
||||
BigInteger raw = 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;
|
||||
if (enemyTotal > 0)
|
||||
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
|
||||
|
||||
int siegePercent = 0;
|
||||
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
|
||||
|
||||
total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent);
|
||||
// воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e
|
||||
BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum);
|
||||
if (term > 0) raw += term;
|
||||
}
|
||||
total = Math.Max(0, total * (100 + bonusPercent) / 100);
|
||||
total = total * conditionPercent / 100;
|
||||
return total * (1000 + luckPermille) / 1000;
|
||||
|
||||
ConditionFactor(conditions, out BigInteger condNum, out BigInteger condDen);
|
||||
conditionPercent = (int)(condNum * 100 / condDen);
|
||||
return new Fraction
|
||||
{
|
||||
N = raw * Math.Max(0, 100 + bonusPercent) * condNum,
|
||||
D = (BigInteger)e * 100 * condDen
|
||||
};
|
||||
}
|
||||
|
||||
// Случайность стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
|
||||
static Fraction WithLuck(Fraction power, int luckPermille)
|
||||
{
|
||||
return new Fraction { N = power.N * (1000 + luckPermille), D = power.D * 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;
|
||||
ulong x = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side);
|
||||
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)
|
||||
// Отдельный поток случайности для округления потерь (№13): своя "соль" на каждую сторону и тип войск,
|
||||
// не пересекается с удачей. Возвращает число от 0 до 2^32 - 1.
|
||||
static ulong LossRoll(long seed, int side, int type)
|
||||
{
|
||||
ulong x = Mix(((ulong)seed ^ 0xD1B54A32D192ED03UL) + 0x9E3779B97F4A7C15UL * (ulong)(16 * side + type + 1));
|
||||
return x >> 32;
|
||||
}
|
||||
|
||||
static ulong Mix(ulong x)
|
||||
{
|
||||
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
|
||||
// Потери каждого типа (дробью): units x доля выбывших по кривой (№14).
|
||||
// Целая часть гарантирована, дробная -- шанс ещё одной потери (№13).
|
||||
int[] Losses(int[] units, Fraction weaker, Fraction stronger, bool isWeak, long seed, int side, Rounding rounding)
|
||||
{
|
||||
var lost = new int[TroopType.Count];
|
||||
LossShare(weaker, stronger, isWeak, out BigInteger shareNum, out BigInteger shareDen);
|
||||
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));
|
||||
|
||||
BigInteger num = (BigInteger)units[i] * shareNum;
|
||||
BigInteger den = 1000 * shareDen;
|
||||
|
||||
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
|
||||
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side, i) * den < rest * (BigInteger.One << 32));
|
||||
BigInteger l = whole + (extra ? 1 : 0);
|
||||
lost[i] = (int)BigInteger.Min(units[i], l);
|
||||
}
|
||||
return lost;
|
||||
}
|
||||
|
||||
// Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок
|
||||
// из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше.
|
||||
int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding)
|
||||
{
|
||||
var dead = new int[TroopType.Count];
|
||||
DeadShare(own, enemy, out BigInteger shareNum, out BigInteger shareDen);
|
||||
for (int i = 0; i < TroopType.Count; i++)
|
||||
{
|
||||
if (outUnits[i] <= 0) continue;
|
||||
BigInteger num = (BigInteger)outUnits[i] * shareNum;
|
||||
BigInteger den = 1000 * shareDen;
|
||||
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
|
||||
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|
||||
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side + 8, i) * den < rest * (BigInteger.One << 32));
|
||||
dead[i] = (int)BigInteger.Min(outUnits[i], whole + (extra ? 1 : 0));
|
||||
}
|
||||
return dead;
|
||||
}
|
||||
|
||||
// Доля погибших (в десятых долях процента, дробью) при соотношении сил стороны R = 1000 * own / enemy
|
||||
void DeadShare(Fraction own, Fraction enemy, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
int last = deadRatio.Length - 1;
|
||||
if (enemy.N.IsZero) { num = deadShare[last]; den = 1; return; } // у врага нет силы -- как при самом большом перевесе
|
||||
BigInteger rn = 1000 * own.N * enemy.D;
|
||||
BigInteger rd = own.D * enemy.N;
|
||||
if (rn <= deadRatio[0] * rd) { num = deadShare[0]; den = 1; return; }
|
||||
if (rn >= deadRatio[last] * rd) { num = deadShare[last]; den = 1; return; }
|
||||
int i = 0;
|
||||
while (rn >= deadRatio[i + 1] * rd) i++;
|
||||
long span = deadRatio[i + 1] - deadRatio[i];
|
||||
num = (BigInteger)deadShare[i] * span * rd + (BigInteger)(deadShare[i + 1] - deadShare[i]) * (rn - deadRatio[i] * rd);
|
||||
den = span * rd;
|
||||
}
|
||||
|
||||
static int[] Units(int[] units, int count)
|
||||
{
|
||||
var result = new int[TroopType.Count];
|
||||
|
||||
@@ -22,6 +22,18 @@ public interface IEconomy
|
||||
float GetCapacity(ResourceType type);
|
||||
}
|
||||
|
||||
// Защита ресурсов от грабежа (01.11, ТЕСТ). Сколько ресурса нельзя отнять в набеге: часть запаса прячут
|
||||
// Склады и Амбары. Добыча набега по ресурсу = Lootable. Реализует слой приложения (App/ResourceProtection)
|
||||
// по зданиям города; модули Боя и Мира берут отсюда, сколько можно унести у города.
|
||||
// Кристаллы (решение 04.11): недоступны нападающему, пока он не изучил технологию «Грабёж».
|
||||
// Технологий в игре ещё нет -- изучена ли она, передаёт тот, кто считает набег (attackerHasPlunder).
|
||||
public interface IResourceProtection
|
||||
{
|
||||
float Protected(ResourceType type); // сколько спрятано от нападающего без «Грабежа»
|
||||
float Lootable(ResourceType type); // сколько можно унести без «Грабежа» (кристаллы -- 0)
|
||||
float Lootable(ResourceType type, bool attackerHasPlunder); // с учётом технологии нападающего
|
||||
}
|
||||
|
||||
// Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService).
|
||||
public interface IProduction
|
||||
{
|
||||
@@ -86,6 +98,33 @@ public interface IPopulation
|
||||
}
|
||||
|
||||
// ---------------- Армия: войска ----------------
|
||||
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
|
||||
public struct HealingInfo
|
||||
{
|
||||
public int Id;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long StartMs;
|
||||
public long EndMs;
|
||||
}
|
||||
|
||||
// Госпиталь -- здание Поселения (решения 31.10, п. 7): уровень, вместимость, улучшения.
|
||||
// Пока здания нет, контракт не зарегистрирован -- Армия берёт временное число мест из таблицы HospitalConfig.
|
||||
public interface IHospital
|
||||
{
|
||||
int Beds { get; } // мест для лечения: регламент 31.10 -- 20..60 по уровням I..V
|
||||
}
|
||||
|
||||
// Заказ обучения в Казармах. Время -- миллисекунды часов игры (GameClock.UnixNowMs)
|
||||
public struct TrainingOrderInfo
|
||||
{
|
||||
public int Id; // номер заказа -- не повторяется
|
||||
public int Type; // тип войск (TroopType)
|
||||
public int Count;
|
||||
public long StartMs; // когда начнётся (заказы идут друг за другом)
|
||||
public long EndMs; // когда воины будут готовы
|
||||
}
|
||||
|
||||
public interface IArmy
|
||||
{
|
||||
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
|
||||
@@ -102,8 +141,13 @@ public interface IArmy
|
||||
void AddTroops(int type, int count); // обучены / вернулись
|
||||
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
|
||||
|
||||
// Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно.
|
||||
// База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены.
|
||||
int MarchSlots { get; }
|
||||
int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот)
|
||||
|
||||
// Забрать свободных воинов в поход (запоминает состав похода).
|
||||
// false -- ничего не взято: не хватает воинов или поход с таким номером уже есть.
|
||||
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
|
||||
bool TakeForMarch(int marchId, int count);
|
||||
// Вернуть выживших из похода по тем же типам войск.
|
||||
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
|
||||
@@ -113,6 +157,34 @@ public interface IArmy
|
||||
int[] GetMarchUnits(int marchId);
|
||||
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
|
||||
bool ReturnUnits(int marchId, int[] survivorUnits);
|
||||
|
||||
// ---- Раненые и лечение (решения по 8 вопросам Саши, 31.10, п. 7): состояние и очередь лечения -- у Армии ----
|
||||
// Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (IHospital), цену лечения списывает Экономика.
|
||||
// Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11).
|
||||
int WoundedTotal { get; } // раненых всего: ждут места + лечатся
|
||||
int GetWoundedWaiting(int type); // ждут места, по типам
|
||||
IReadOnlyList<HealingInfo> Healing { get; } // лечатся сейчас
|
||||
int HospitalBeds { get; } // мест в госпитале сейчас
|
||||
// Вернуть из похода выживших и раненых точно по типам (раненые -- в госпиталь в момент nowMs).
|
||||
// Только известный поход и только один раз; больше, чем ушло, не вернётся.
|
||||
bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs);
|
||||
// Закончить лечение, срок которого наступил (и офлайн), и положить ждущих на освободившиеся места.
|
||||
// Вылеченные возвращаются в город (SoldiersHealedEvent, один раз на группу). Возвращает, сколько групп вылечено.
|
||||
int ProcessHealing(long nowMs);
|
||||
|
||||
// ---- Очередь обучения (решения по 8 вопросам Саши, 31.10, п. 8): единственный владелец -- Армия ----
|
||||
// Жители для найма -- у Населения (IPopulation: Recruitable, резерв, TryRecruit, CompleteTraining),
|
||||
// размер очереди -- от Казарм (передаёт вызывающий), цену списывает вызывающий через Экономику.
|
||||
IReadOnlyList<TrainingOrderInfo> Training { get; }
|
||||
// Можно ли поставить заказ: очередь не заполнена, жителей хватает. Ничего не меняет; false -- error для игрока.
|
||||
bool CanQueueTraining(int type, int count, int queueSize, out string error);
|
||||
// Поставить заказ в конец очереди, люди уходят из жителей в Казармы. false -- ничего не изменилось.
|
||||
bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error);
|
||||
// Закончить заказы, срок которых наступил к nowMs (и пока игра была закрыта): воины переходят в Армию,
|
||||
// каждый заказ -- один раз (TroopsTrainedEvent). Возвращает, сколько заказов закончено.
|
||||
int ProcessTraining(long nowMs);
|
||||
// Ускорение: все заказы заканчиваются в nowMs
|
||||
int FinishAllTraining(long nowMs);
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир: карта и перемещения ----------------
|
||||
@@ -120,6 +192,57 @@ public interface IWorld
|
||||
{
|
||||
int ActiveMarches { get; }
|
||||
bool TryStartMarch(string targetId, int soldiers, out string error);
|
||||
// Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода
|
||||
bool TryStartScout(string targetId, int soldiers, out string error);
|
||||
}
|
||||
|
||||
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
|
||||
// Координаты и марши ведёт Мир, разведка только читает их через этот контракт -- без прямых ссылок
|
||||
// World <-> Intelligence. Координаты -- милли-единицы карты (1000 = 1 клетка).
|
||||
public struct ArmyOnMap
|
||||
{
|
||||
public string Id; // устойчивый номер армии на карте ("march_12")
|
||||
public string OwnerId; // владелец (LocalPlayer.Id -- свои войска)
|
||||
public int X, Y; // где сейчас
|
||||
public int ToX, ToY; // куда движется
|
||||
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
|
||||
}
|
||||
|
||||
public interface IWorldMap
|
||||
{
|
||||
int HalfWidth { get; } // границы карты от центра, милли-единицы
|
||||
int HalfHeight { get; }
|
||||
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
|
||||
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
|
||||
void GetArmies(long timeMs, List<ArmyOnMap> result);
|
||||
}
|
||||
|
||||
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
|
||||
public enum MapVisibility
|
||||
{
|
||||
Unexplored, // неизведано
|
||||
Explored, // исследовано: география известна, текущие движения не видны
|
||||
Observed // наблюдается сейчас
|
||||
}
|
||||
|
||||
// Что игрок знает о чужой армии: только приблизительно и только на момент наблюдения
|
||||
public struct KnownContact
|
||||
{
|
||||
public string ArmyId;
|
||||
public bool Identified; // владелец опознан
|
||||
public string OwnerId; // null, пока не опознан
|
||||
public bool Observed; // видна прямо сейчас; false -- последний контакт (устаревшие данные)
|
||||
public int X, Y, ToX, ToY; // где видели последний раз и куда шла
|
||||
public int ApproxMin, ApproxMax; // примерная численность (±20%), одна на событие наблюдения
|
||||
public long LastSeenMs; // когда видели последний раз
|
||||
}
|
||||
|
||||
public interface IIntelligence
|
||||
{
|
||||
int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам)
|
||||
MapVisibility GetVisibility(int x, int y); // по последнему расчёту
|
||||
IReadOnlyList<KnownContact> Contacts { get; }
|
||||
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
|
||||
}
|
||||
|
||||
// ---------------- Боевая система: урон, потери, исход ----------------
|
||||
@@ -187,7 +310,7 @@ public struct BattleOutcome
|
||||
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
|
||||
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
|
||||
public int[] AttackerDeadUnits, DefenderDeadUnits;
|
||||
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // пока нули: доля раненых не утверждена командой
|
||||
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7): выбывшие минус погибшие
|
||||
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
|
||||
}
|
||||
|
||||
@@ -202,9 +325,10 @@ public enum BattleChance
|
||||
public struct BattleForecast
|
||||
{
|
||||
public BattleChance Chance;
|
||||
public int WinChancePermille; // шанс победы нападающего в десятых долях процента: 273 = 27,3% (решение №11)
|
||||
public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности
|
||||
public long DefenderPowerMin, DefenderPowerMax;
|
||||
public int AttackerLossesMin, AttackerLossesMax;
|
||||
public int AttackerLossesMin, AttackerLossesMax; // с учётом округления потерь (решение №13)
|
||||
}
|
||||
|
||||
public interface IBattleSystem
|
||||
|
||||
@@ -32,6 +32,15 @@ public struct GameTimeAdvancedEvent
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11).
|
||||
// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick.
|
||||
// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг
|
||||
// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна.
|
||||
public struct GameTimeSimulatedEvent
|
||||
{
|
||||
public long UnixNow;
|
||||
}
|
||||
|
||||
// ---------------- Население ----------------
|
||||
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
|
||||
public struct PopulationChangedEvent
|
||||
@@ -62,19 +71,44 @@ public struct TroopsChangedEvent
|
||||
public int AtHome;
|
||||
public int InMarches;
|
||||
public int Garrison; // из AtHome -- в гарнизоне
|
||||
public int Wounded; // раненые: в госпитале или ждут места (не в AtHome)
|
||||
}
|
||||
|
||||
// Раненые вылечены и вернулись в город. Один раз на группу лечения (BatchId), TimeMs -- когда закончили
|
||||
public struct SoldiersHealedEvent
|
||||
{
|
||||
public int BatchId;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Заказ обучения закончен: воины уже в Армии. Приходит один раз на заказ (OrderId), TimeMs -- когда был готов
|
||||
public struct TroopsTrainedEvent
|
||||
{
|
||||
public int OrderId;
|
||||
public int Type;
|
||||
public int Count;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// ---------------- Глобальный мир ----------------
|
||||
// У событий похода -- номер похода и точное время по часам игры (документ 02 комплекта от 29.10.2026):
|
||||
// TimeMs -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты).
|
||||
public struct MarchStartedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long TimeMs; // выступление
|
||||
public string TargetId;
|
||||
public int Soldiers;
|
||||
public bool IsScout; // поход «Разведка»: без боя и добычи
|
||||
}
|
||||
|
||||
public struct MarchBattleResolvedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long BattleId; // номер боя (= Seed): по нему бой можно перепроверить
|
||||
public long TimeMs; // приход к цели и бой
|
||||
public string TargetId;
|
||||
public bool Victory;
|
||||
public int Soldiers;
|
||||
@@ -84,10 +118,14 @@ public struct MarchBattleResolvedEvent
|
||||
public struct MarchReturnedEvent
|
||||
{
|
||||
public int MarchId;
|
||||
public long TimeMs; // возвращение домой
|
||||
public bool Victory;
|
||||
public int Survivors;
|
||||
public int Sent; // сколько воинов уходило в поход
|
||||
public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все
|
||||
public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все
|
||||
public bool IsGather; // поход на ресурсную точку (сбор, а не бой)
|
||||
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
|
||||
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
|
||||
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
|
||||
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
|
||||
}
|
||||
@@ -123,3 +161,36 @@ public struct NewGameStartedEvent { }
|
||||
public struct GameSavedEvent { }
|
||||
// Сохранения сброшены, все модули уже в состоянии новой игры
|
||||
public struct GameResetEvent { }
|
||||
|
||||
// ---------------- Мир -> Разведка ----------------
|
||||
// Мир сейчас продвинет время до ToMs: разведка успевает досчитать наблюдение по армиям, которые ещё на карте
|
||||
// (после продвижения вернувшиеся походы исчезают). Публикует MarchManager перед каждым расчётом мира.
|
||||
public struct WorldAdvancingEvent
|
||||
{
|
||||
public long ToMs;
|
||||
}
|
||||
|
||||
// ---------------- Разведка ----------------
|
||||
// У всех событий -- устойчивый номер события (армия + номер наблюдения) и время по часам игры (ТЗ разведки, раздел 10)
|
||||
public struct ArmySpottedEvent // чужая армия вошла в зону обзора
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
public long TimeMs;
|
||||
public int ApproxMin, ApproxMax;
|
||||
}
|
||||
|
||||
public struct ArmyLostContactEvent // чужая армия вышла из зоны обзора -- остаётся метка последнего контакта
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
|
||||
{
|
||||
public string EventId;
|
||||
public string ArmyId;
|
||||
public string OwnerId;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public static class GameClock
|
||||
|
||||
public static DateTime UtcNow => DateTimeOffset.FromUnixTimeSeconds(UnixNow).UtcDateTime;
|
||||
|
||||
// То же в миллисекундах -- для таймеров, которые идут плавно (обучение войск, марши)
|
||||
public static long UnixNowMs => (RealUnixSource != null ? RealUnixSource() * 1000 : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
|
||||
+ offsetSeconds * 1000;
|
||||
|
||||
// Промотать время вперёд (отладка). Модули узнают об этом из GameTimeAdvancedEvent и досчитываются.
|
||||
public static void Advance(long seconds)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b19799fef59420d948583d7bd00e661
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Bogatyri.Intelligence",
|
||||
"rootNamespace": "",
|
||||
"references": ["Bogatyri.Core"],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 263eeba80895448da32cd4c9816b18f3
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
// МОДУЛЬ РАЗВЕДКИ, этап М1: туман войны, источники обзора, чужие армии, последний контакт.
|
||||
// Документ: Docs/Modules/Intelligence.md. ТЗ разведки 31.10.2026, решение №46: отдельная сборка Bogatyri.Intelligence.
|
||||
//
|
||||
// Без Unity. Карту и армии не хранит и не двигает -- читает их у Мира через контракт IWorldMap.
|
||||
// Своё: туман (исследованные клетки), известные контакты, номера событий наблюдения. Чужие данные не меняет.
|
||||
//
|
||||
// Числа -- ТЕСТ, в игре из таблицы IntelligenceConfig.
|
||||
public sealed class IntelligenceRules
|
||||
{
|
||||
public int SettlementRadius = 5000; // обзор своего поселения: 5 клеток
|
||||
public int ArmyRadius = 1000; // обзор обычной армии: 1 клетка
|
||||
public int[] ObservationPercent = { 0, 10, 20, 30 }; // технология «Обзор» I/II/III -- не складывается, заменяет
|
||||
public long LastContactMs = 10 * 60_000; // последний контакт живёт 10 РЕАЛЬНЫХ минут
|
||||
public int ApproxPercent = 20; // примерная численность: ±20%
|
||||
public int IdentifyPercent = 50; // опознание чужого владельца: 50% на событие наблюдения
|
||||
public long SampleStepMs = 10_000; // шаг расчёта, пока игра была закрыта: 10 реальных секунд
|
||||
public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов
|
||||
public int CellSize = 1000; // клетка тумана = клетка карты
|
||||
|
||||
public static IntelligenceRules Default => new IntelligenceRules();
|
||||
}
|
||||
|
||||
public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
{
|
||||
// Что игрок знает о чужой армии. Номер наблюдения -- для устойчивых событий и воспроизводимых бросков
|
||||
class Contact
|
||||
{
|
||||
public string armyId;
|
||||
public string ownerId; // настоящий владелец (из Мира) -- игроку показывается только после опознания
|
||||
public bool identified;
|
||||
public bool observed;
|
||||
public int x, y, toX, toY;
|
||||
public int approxMin, approxMax;
|
||||
public long lastSeenMs;
|
||||
public int observation; // сколько раз армия входила в обзор (событие наблюдения)
|
||||
}
|
||||
|
||||
private readonly IntelligenceRules rules;
|
||||
private readonly Func<IWorldMap> worldMap;
|
||||
private readonly string localPlayer;
|
||||
private readonly Dictionary<string, Contact> contacts = new Dictionary<string, Contact>();
|
||||
private readonly List<KnownContact> contactView = new List<KnownContact>();
|
||||
private readonly List<ArmyOnMap> armies = new List<ArmyOnMap>();
|
||||
private readonly List<(int x, int y, int r)> sources = new List<(int, int, int)>();
|
||||
private readonly HashSet<string> seen = new HashSet<string>();
|
||||
private bool[] explored = Array.Empty<bool>();
|
||||
private int gridW, gridH, halfW, halfH;
|
||||
private long lastTickMs;
|
||||
private bool viewDirty = true;
|
||||
|
||||
public IntelligenceService(IntelligenceRules rules = null, Func<IWorldMap> worldMap = null, string localPlayerId = LocalPlayer.Id)
|
||||
{
|
||||
this.rules = rules ?? IntelligenceRules.Default;
|
||||
this.worldMap = worldMap ?? (() => GameServices.TryGet(out IWorldMap m) ? m : null);
|
||||
localPlayer = localPlayerId;
|
||||
}
|
||||
|
||||
public IntelligenceRules Rules => rules;
|
||||
|
||||
// ---------------- Контракт IIntelligence ----------------
|
||||
|
||||
private int observationLevel;
|
||||
public int ObservationLevel
|
||||
{
|
||||
get => observationLevel;
|
||||
set => observationLevel = Math.Max(0, Math.Min(rules.ObservationPercent.Length - 1, value));
|
||||
}
|
||||
|
||||
// Радиус с «Обзором»: процент текущего уровня, дробные радиусы без округления до клеток
|
||||
public int Radius(int baseRadius)
|
||||
{
|
||||
return (int)((long)baseRadius * (100 + rules.ObservationPercent[observationLevel]) / 100);
|
||||
}
|
||||
|
||||
public MapVisibility GetVisibility(int x, int y)
|
||||
{
|
||||
foreach (var s in sources)
|
||||
if (Inside(x, y, s.x, s.y, s.r)) return MapVisibility.Observed;
|
||||
int cell = Cell(x, y);
|
||||
return cell >= 0 && explored[cell] ? MapVisibility.Explored : MapVisibility.Unexplored;
|
||||
}
|
||||
|
||||
public IReadOnlyList<KnownContact> Contacts
|
||||
{
|
||||
get
|
||||
{
|
||||
if (viewDirty)
|
||||
{
|
||||
contactView.Clear();
|
||||
foreach (var c in contacts.Values)
|
||||
contactView.Add(new KnownContact
|
||||
{
|
||||
ArmyId = c.armyId,
|
||||
Identified = c.identified,
|
||||
OwnerId = c.identified ? c.ownerId : null,
|
||||
Observed = c.observed,
|
||||
X = c.x, Y = c.y, ToX = c.toX, ToY = c.toY,
|
||||
ApproxMin = c.approxMin, ApproxMax = c.approxMax,
|
||||
LastSeenMs = c.lastSeenMs
|
||||
});
|
||||
viewDirty = false;
|
||||
}
|
||||
return contactView;
|
||||
}
|
||||
}
|
||||
|
||||
// Наблюдение от прошлого расчёта до nowMs шагами SampleStepMs: армия, прошедшая через обзор,
|
||||
// пока игра была закрыта, тоже будет замечена -- в хронологическом порядке (ТЗ, приёмка 9)
|
||||
public void ObserveUntil(long nowMs)
|
||||
{
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null) return;
|
||||
EnsureGrid(map);
|
||||
|
||||
long from = lastTickMs == 0 ? nowMs : Math.Max(lastTickMs, nowMs - rules.MaxCatchUpMs);
|
||||
if (nowMs < from) return; // время не идёт назад: повторный расчёт ничего не меняет
|
||||
for (long t = from + rules.SampleStepMs; t < nowMs; t += rules.SampleStepMs) Sample(map, t);
|
||||
Sample(map, nowMs);
|
||||
lastTickMs = nowMs;
|
||||
|
||||
// Последний контакт стареет и удаляется через LastContactMs (ТЗ, раздел 2)
|
||||
var expired = new List<string>();
|
||||
foreach (var c in contacts.Values)
|
||||
if (!c.observed && nowMs - c.lastSeenMs >= rules.LastContactMs) expired.Add(c.armyId);
|
||||
foreach (var id in expired) contacts.Remove(id);
|
||||
if (expired.Count > 0) viewDirty = true;
|
||||
}
|
||||
|
||||
void Sample(IWorldMap map, long t)
|
||||
{
|
||||
armies.Clear();
|
||||
map.GetArmies(t, armies);
|
||||
|
||||
// Источники обзора: своё поселение и свои армии (башни и герой-разведчик -- позже)
|
||||
sources.Clear();
|
||||
if (map.TryGetSettlement(localPlayer, out int sx, out int sy)) sources.Add((sx, sy, Radius(rules.SettlementRadius)));
|
||||
foreach (var a in armies)
|
||||
if (a.OwnerId == localPlayer) sources.Add((a.X, a.Y, Radius(rules.ArmyRadius)));
|
||||
foreach (var s in sources) Explore(s.x, s.y, s.r);
|
||||
|
||||
seen.Clear();
|
||||
foreach (var a in armies)
|
||||
{
|
||||
if (a.OwnerId == localPlayer) continue; // свои -- не разведка
|
||||
bool inView = false;
|
||||
foreach (var s in sources) if (Inside(a.X, a.Y, s.x, s.y, s.r)) { inView = true; break; }
|
||||
if (!inView) continue;
|
||||
seen.Add(a.Id);
|
||||
Observe(a, t);
|
||||
}
|
||||
|
||||
// Ушла из обзора (или исчезла с карты) -- контакт потерян, данные больше не обновляются
|
||||
foreach (var c in contacts.Values)
|
||||
{
|
||||
if (!c.observed || seen.Contains(c.armyId)) continue;
|
||||
c.observed = false;
|
||||
viewDirty = true;
|
||||
EventBus.Publish(new ArmyLostContactEvent { EventId = EventId(c), ArmyId = c.armyId, TimeMs = t });
|
||||
}
|
||||
}
|
||||
|
||||
void Observe(ArmyOnMap a, long t)
|
||||
{
|
||||
if (!contacts.TryGetValue(a.Id, out Contact c))
|
||||
{
|
||||
c = new Contact { armyId = a.Id, ownerId = a.OwnerId };
|
||||
contacts[a.Id] = c;
|
||||
}
|
||||
|
||||
if (!c.observed)
|
||||
{
|
||||
// Новое событие наблюдения: примерная численность и бросок опознания -- один раз на событие
|
||||
c.observed = true;
|
||||
c.observation++;
|
||||
int width = Math.Max(1, a.Soldiers * 2 * rules.ApproxPercent / 100); // ±20% -> окно 40%
|
||||
int shift = (int)(Roll(a.Id, c.observation, 1) % (ulong)(width + 1));
|
||||
c.approxMin = Math.Max(1, a.Soldiers - shift); // настоящая численность всегда внутри окна
|
||||
c.approxMax = c.approxMin + width;
|
||||
EventBus.Publish(new ArmySpottedEvent
|
||||
{ EventId = EventId(c), ArmyId = a.Id, TimeMs = t, ApproxMin = c.approxMin, ApproxMax = c.approxMax });
|
||||
|
||||
if (!c.identified && Roll(a.Id, c.observation, 2) % 100 < (ulong)rules.IdentifyPercent)
|
||||
{
|
||||
c.identified = true;
|
||||
EventBus.Publish(new ArmyIdentifiedEvent { EventId = EventId(c), ArmyId = a.Id, OwnerId = a.OwnerId, TimeMs = t });
|
||||
}
|
||||
}
|
||||
|
||||
c.x = a.X; c.y = a.Y; c.toX = a.ToX; c.toY = a.ToY;
|
||||
c.lastSeenMs = t;
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
static string EventId(Contact c) => c.armyId + "#" + c.observation.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// Детерминированный бросок: армия + номер наблюдения + назначение. Повторный расчёт, загрузка или клик
|
||||
// не перебрасывают -- одно событие наблюдения, один результат (ТЗ, приёмка 5)
|
||||
static ulong Roll(string armyId, int observation, int purpose)
|
||||
{
|
||||
ulong h = 14695981039346656037UL;
|
||||
foreach (char ch in armyId) h = (h ^ ch) * 1099511628211UL;
|
||||
h ^= (ulong)observation * 0x9E3779B97F4A7C15UL + (ulong)purpose;
|
||||
h = (h ^ (h >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||
h = (h ^ (h >> 27)) * 0x94D049BB133111EBUL;
|
||||
return h ^ (h >> 31);
|
||||
}
|
||||
|
||||
// ---------------- Туман: исследованные клетки ----------------
|
||||
|
||||
void EnsureGrid(IWorldMap map)
|
||||
{
|
||||
if (map.HalfWidth == halfW && map.HalfHeight == halfH && explored.Length > 0) return;
|
||||
halfW = map.HalfWidth;
|
||||
halfH = map.HalfHeight;
|
||||
gridW = Math.Max(1, (2 * halfW + rules.CellSize - 1) / rules.CellSize);
|
||||
gridH = Math.Max(1, (2 * halfH + rules.CellSize - 1) / rules.CellSize);
|
||||
var old = explored;
|
||||
explored = new bool[gridW * gridH];
|
||||
if (old.Length == explored.Length) Array.Copy(old, explored, old.Length);
|
||||
}
|
||||
|
||||
int Cell(int x, int y)
|
||||
{
|
||||
if (gridW == 0) return -1;
|
||||
int cx = (x + halfW) / rules.CellSize, cy = (y + halfH) / rules.CellSize;
|
||||
if (cx < 0 || cy < 0 || cx >= gridW || cy >= gridH) return -1;
|
||||
return cy * gridW + cx;
|
||||
}
|
||||
|
||||
// Исследована клетка, центр которой попал в круг обзора
|
||||
void Explore(int x, int y, int r)
|
||||
{
|
||||
int c = rules.CellSize;
|
||||
for (int cy = Math.Max(0, (y - r + halfH) / c); cy <= Math.Min(gridH - 1, (y + r + halfH) / c); cy++)
|
||||
for (int cx = Math.Max(0, (x - r + halfW) / c); cx <= Math.Min(gridW - 1, (x + r + halfW) / c); cx++)
|
||||
{
|
||||
int px = cx * c + c / 2 - halfW, py = cy * c + c / 2 - halfH;
|
||||
if (Inside(px, py, x, y, r)) explored[cy * gridW + cx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
static bool Inside(int x, int y, int cx, int cy, int r)
|
||||
{
|
||||
long dx = x - cx, dy = y - cy;
|
||||
return dx * dx + dy * dy <= (long)r * r;
|
||||
}
|
||||
|
||||
public int ExploredCells
|
||||
{
|
||||
get { int n = 0; foreach (var e in explored) if (e) n++; return n; }
|
||||
}
|
||||
|
||||
// ---------------- Сохранение (секция "intelligence") ----------------
|
||||
// Простые строки (модуль без Unity):
|
||||
// tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex>
|
||||
// contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения>
|
||||
|
||||
public string SaveKey => "intelligence";
|
||||
|
||||
public string Capture()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("tick=").Append(lastTickMs.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("obzor=").Append(observationLevel.ToString(CultureInfo.InvariantCulture)).Append('\n');
|
||||
sb.Append("grid=").Append(gridW).Append('x').Append(gridH).Append(':').Append(PackBits(explored)).Append('\n');
|
||||
foreach (var c in contacts.Values)
|
||||
sb.Append("contact=").Append(string.Join("|", new[]
|
||||
{
|
||||
c.armyId, c.ownerId ?? "", c.identified ? "1" : "0", c.observed ? "1" : "0",
|
||||
N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax),
|
||||
c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation)
|
||||
})).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Restore(string data)
|
||||
{
|
||||
ResetState();
|
||||
if (string.IsNullOrEmpty(data)) return;
|
||||
foreach (var raw in data.Split('\n'))
|
||||
{
|
||||
string line = raw.Trim();
|
||||
if (line.StartsWith("tick=")) long.TryParse(line.Substring(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out lastTickMs);
|
||||
else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl;
|
||||
else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5));
|
||||
else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|'));
|
||||
}
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
public void ResetState()
|
||||
{
|
||||
contacts.Clear();
|
||||
explored = Array.Empty<bool>();
|
||||
gridW = gridH = halfW = halfH = 0;
|
||||
lastTickMs = 0;
|
||||
observationLevel = 0;
|
||||
sources.Clear();
|
||||
viewDirty = true;
|
||||
}
|
||||
|
||||
void RestoreGrid(string s)
|
||||
{
|
||||
int x = s.IndexOf('x'), colon = s.IndexOf(':');
|
||||
if (x < 0 || colon < x) return;
|
||||
if (!int.TryParse(s.Substring(0, x), out int w) || !int.TryParse(s.Substring(x + 1, colon - x - 1), out int h)) return;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
gridW = w; gridH = h;
|
||||
explored = UnpackBits(s.Substring(colon + 1), w * h);
|
||||
// halfW/halfH уточнит EnsureGrid при первом расчёте; размер сетки совпадёт -- клетки сохранятся
|
||||
}
|
||||
|
||||
void RestoreContact(string[] p)
|
||||
{
|
||||
if (p.Length < 12) return;
|
||||
var c = new Contact
|
||||
{
|
||||
armyId = p[0], ownerId = p[1].Length > 0 ? p[1] : null,
|
||||
identified = p[2] == "1", observed = p[3] == "1",
|
||||
x = I(p[4]), y = I(p[5]), toX = I(p[6]), toY = I(p[7]),
|
||||
approxMin = I(p[8]), approxMax = I(p[9]),
|
||||
observation = I(p[11])
|
||||
};
|
||||
long.TryParse(p[10], NumberStyles.Integer, CultureInfo.InvariantCulture, out c.lastSeenMs);
|
||||
if (!string.IsNullOrEmpty(c.armyId)) contacts[c.armyId] = c;
|
||||
}
|
||||
|
||||
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
|
||||
static int I(string s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : 0;
|
||||
|
||||
static string PackBits(bool[] bits)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (int i = 0; i < bits.Length; i += 4)
|
||||
{
|
||||
int v = 0;
|
||||
for (int k = 0; k < 4 && i + k < bits.Length; k++) if (bits[i + k]) v |= 1 << k;
|
||||
sb.Append("0123456789abcdef"[v]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
static bool[] UnpackBits(string hex, int count)
|
||||
{
|
||||
var bits = new bool[count];
|
||||
for (int i = 0; i < hex.Length && i * 4 < count; i++)
|
||||
{
|
||||
int v = "0123456789abcdef".IndexOf(char.ToLowerInvariant(hex[i]));
|
||||
if (v < 0) continue;
|
||||
for (int k = 0; k < 4 && i * 4 + k < count; k++) bits[i * 4 + k] = (v & (1 << k)) != 0;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6be01b8e525a455593482d7159414485
|
||||
@@ -15,7 +15,7 @@ using System.Text;
|
||||
// свободный житель = Civilians - Workers (Idle)
|
||||
// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных)
|
||||
// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает)
|
||||
// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию)
|
||||
// воин = Soldiers (хранит Армия: в городе, в походах и раненые; RecruitsTrained убирает новобранцев до зачисления в Армию)
|
||||
// Total = Civilians + InTraining + Soldiers -- сумма всех групп.
|
||||
// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок.
|
||||
// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять.
|
||||
@@ -79,7 +79,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
get
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army)) return 0;
|
||||
return army.SoldiersAtHome + army.TroopsInMarches;
|
||||
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal; // раненые -- тоже люди (решения 31.10, п. 7)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,17 +221,18 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
{
|
||||
lastTickUnix = unixNow; // первый расчёт: отсюда и считаем
|
||||
Changed();
|
||||
SimulatedUntil(unixNow);
|
||||
return;
|
||||
}
|
||||
|
||||
long elapsed = unixNow - lastTickUnix;
|
||||
if (elapsed < 0)
|
||||
{
|
||||
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас"
|
||||
lastTickUnix = unixNow;
|
||||
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне). Метку НЕ двигаем назад:
|
||||
// это время уже посчитано, и когда часы снова дойдут до него, оно не начислится второй раз (04.11).
|
||||
return;
|
||||
}
|
||||
if (elapsed == 0) return;
|
||||
if (elapsed == 0) { SimulatedUntil(unixNow); return; }
|
||||
|
||||
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
|
||||
if (elapsed > max) elapsed = max;
|
||||
@@ -239,6 +240,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
Simulate(unixNow - elapsed, elapsed);
|
||||
lastTickUnix = unixNow;
|
||||
PruneFamine(unixNow);
|
||||
SimulatedUntil(unixNow);
|
||||
}
|
||||
|
||||
// Расчёт дошёл до момента: то, что случается по времени (стройка закончилась), применяется сейчас (GameTimeSimulatedEvent)
|
||||
static void SimulatedUntil(long unixNow)
|
||||
{
|
||||
EventBus.Publish(new GameTimeSimulatedEvent { UnixNow = unixNow });
|
||||
}
|
||||
|
||||
// ---------------- Расчёт ----------------
|
||||
@@ -260,6 +268,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
|
||||
remaining -= step;
|
||||
double hours = step / (double)SecondsPerHour;
|
||||
|
||||
// Расчёт дошёл до начала шага: стройки, закончившиеся к этому моменту, вступают в силу,
|
||||
// и добыча этого шага считается уже с ними (а не с начала офлайна)
|
||||
SimulatedUntil(stepStart);
|
||||
|
||||
// 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест
|
||||
production?.Produce(hours);
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ public class BuildGrid : MonoBehaviour
|
||||
[Header("Где можно строить")]
|
||||
[Tooltip("Посчитать область самому: старые участки + Ратуша + запас")]
|
||||
public bool autoArea = true;
|
||||
|
||||
// Здание, которое сейчас переносят: его клетки считаются свободными (режим стройки, перенос)
|
||||
public static GameObject IgnoredBuilding;
|
||||
[Tooltip("Запас вокруг старых участков, клеток")]
|
||||
public int padding = 3;
|
||||
public Vector2 areaMin = new Vector2(-34, -10);
|
||||
@@ -218,6 +221,7 @@ public class BuildGrid : MonoBehaviour
|
||||
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре
|
||||
if (IgnoredBuilding != null && id.gameObject == IgnoredBuilding) continue; // его переносят
|
||||
BuildingData data = bm != null ? bm.FindData(id.buildingName) : null;
|
||||
Rect r;
|
||||
if (data != null)
|
||||
|
||||
@@ -170,6 +170,8 @@ public class BuildManager : MonoBehaviour
|
||||
var grid = BuildGrid.Instance;
|
||||
grid.Refresh(); // занятость на этот момент, а не 0.3 с назад
|
||||
if (!CanBuildAt(data, cell)) return null;
|
||||
long buildMs = BuildTimer.DurationMs(false, 1);
|
||||
if (buildMs > 0 && BuildTimer.FreeBuilders == 0) return null; // все строители заняты
|
||||
|
||||
data.PayCost();
|
||||
|
||||
@@ -180,6 +182,11 @@ public class BuildManager : MonoBehaviour
|
||||
GameObject built = Instantiate(data.prefab, pos, Quaternion.identity);
|
||||
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
|
||||
built.AddComponent<PlacedBuildingId>().buildingName = data.buildingName;
|
||||
if (buildMs > 0)
|
||||
{
|
||||
long now = GameClock.UnixNowMs;
|
||||
BuildTimer.Begin(built, BuildTimer.Kind.Build, 1, now, now + buildMs); // стоит, но не работает, пока строится
|
||||
}
|
||||
|
||||
Debug.Log($"[СТРОЙКА] Построено: {data.buildingName}, клетки {cell} размер {fp.size}");
|
||||
EventBus.Publish(new BuildingPlacedEvent { BuildingName = data.buildingName });
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
|
||||
// Стройка или улучшение здания, которое ещё идёт (01.11, числа -- ТЕСТ).
|
||||
// - Постройка (Kind.Build): здание стоит полупрозрачным и не работает -- его скрипты выключены
|
||||
// (не добывает, не даёт жильё и места, Казармы не учат). Тап по нему -- окно стройки.
|
||||
// - Улучшение (Kind.Upgrade): здание работает на старом уровне, новый уровень -- когда время выйдет.
|
||||
// - Одновременно идёт не больше Builders строек (строители). Ратуша -- тоже стройка.
|
||||
// - Время -- реальное (GameClock.UnixNowMs): идёт и пока игра закрыта, и при перемотке DEV.
|
||||
// - Офлайн (04.11): метка конца хранится в сохранении; после входа стройка завершается в тот момент расчёта
|
||||
// добычи, когда время дошло до её конца (App/ConstructionClock) -- добыча здания идёт с момента завершения.
|
||||
// - Сохраняет модуль Поселения вместе со зданием (startMs, endMs, вид, целевой уровень).
|
||||
// Сколько длится стройка и сколько строителей -- задаёт слой приложения (EconomyConfig): модуль чисел не знает.
|
||||
public class BuildTimer : MonoBehaviour
|
||||
{
|
||||
public enum Kind { Build = 1, Upgrade = 2 }
|
||||
|
||||
public static readonly List<BuildTimer> All = new List<BuildTimer>();
|
||||
|
||||
// Задаёт слой приложения (App/GameModules) из EconomyConfig
|
||||
public static int Builders = 2;
|
||||
// Можно ли завершить стройку, которая кончилась в этот момент (мс). Слой приложения отвечает "да", только когда
|
||||
// расчёт добычи уже дошёл до этого момента (GameTimeSimulatedEvent): иначе после офлайна здание считалось бы
|
||||
// готовым с самого начала офлайна. null -- завершать сразу.
|
||||
public static Func<long, bool> MayComplete;
|
||||
|
||||
// Длительность в мс: (это Ратуша?, до какого уровня; 1 -- постройка). 0 -- мгновенно
|
||||
public static Func<bool, int, long> DurationMs = (townHall, level) => 0;
|
||||
public static int Busy => All.Count;
|
||||
public static int FreeBuilders => Mathf.Max(0, Builders - Busy);
|
||||
|
||||
// Тап по зданию, которое строится (его собственные скрипты выключены) -- интерфейс откроет окно
|
||||
public static event Action<BuildTimer> Tapped;
|
||||
// Стройка закончилась: интерфейс пересчитает город и покажет сообщение
|
||||
public static event Action<BuildTimer> Completed;
|
||||
|
||||
public Kind kind;
|
||||
public long startMs;
|
||||
public long endMs;
|
||||
public int targetLevel = 1;
|
||||
|
||||
readonly List<MonoBehaviour> paused = new List<MonoBehaviour>();
|
||||
readonly List<KeyValuePair<SpriteRenderer, Color>> tinted = new List<KeyValuePair<SpriteRenderer, Color>>();
|
||||
bool finished;
|
||||
Vector3 downPos;
|
||||
|
||||
public bool IsConstruction => kind == Kind.Build;
|
||||
public double SecondsLeft => Math.Max(0, endMs - GameClock.UnixNowMs) / 1000.0;
|
||||
public float Progress => endMs <= startMs ? 1f : Mathf.Clamp01((float)(GameClock.UnixNowMs - startMs) / (endMs - startMs));
|
||||
|
||||
public static BuildTimer Of(GameObject building) => building != null ? building.GetComponent<BuildTimer>() : null;
|
||||
|
||||
public static bool IsConstructing(Component building)
|
||||
{
|
||||
var t = building != null ? building.GetComponent<BuildTimer>() : null;
|
||||
return t != null && !t.finished && t.kind == Kind.Build;
|
||||
}
|
||||
|
||||
// Начать стройку или улучшение. Цену платит тот, кто вызывает. null -- уже идёт стройка этого здания
|
||||
public static BuildTimer Begin(GameObject building, Kind kind, int targetLevel, long startMs, long endMs)
|
||||
{
|
||||
if (building == null) return null;
|
||||
var old = building.GetComponent<BuildTimer>();
|
||||
if (old != null && !old.finished) return null;
|
||||
if (old != null) DestroyImmediate(old); // закончившийся, ещё не убранный Destroy
|
||||
var t = building.AddComponent<BuildTimer>();
|
||||
t.kind = kind;
|
||||
t.targetLevel = Mathf.Max(1, targetLevel);
|
||||
t.startMs = startMs;
|
||||
t.endMs = Math.Max(startMs, endMs);
|
||||
if (kind == Kind.Build) t.PauseBuilding();
|
||||
if (!All.Contains(t)) All.Add(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
// Закончить сейчас (ускорение за кристаллы или время вышло)
|
||||
public void Complete()
|
||||
{
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
All.Remove(this);
|
||||
ResumeBuilding();
|
||||
|
||||
if (kind == Kind.Upgrade)
|
||||
{
|
||||
var townHall = GetComponent<TownHall>();
|
||||
if (townHall != null)
|
||||
{
|
||||
// уровень только растёт: повторное или устаревшее завершение не понизит и не "докупит" уровни
|
||||
townHall.currentLevel = Mathf.Min(townHall.maxLevel, Mathf.Max(townHall.currentLevel, targetLevel));
|
||||
EventBus.Publish(new TownHallLevelChangedEvent { Level = townHall.currentLevel });
|
||||
}
|
||||
else
|
||||
{
|
||||
var leveled = GetComponent<ILeveledBuilding>();
|
||||
if (leveled != null) leveled.Level = Mathf.Max(leveled.Level, targetLevel);
|
||||
}
|
||||
}
|
||||
Completed?.Invoke(this);
|
||||
Destroy(this);
|
||||
}
|
||||
|
||||
// Убрать без результата (загрузка другого сохранения, новая игра)
|
||||
public void Cancel()
|
||||
{
|
||||
finished = true;
|
||||
All.Remove(this);
|
||||
ResumeBuilding();
|
||||
DestroyImmediate(this);
|
||||
}
|
||||
|
||||
// Здание сносится вместе со стройкой: строитель свободен сразу, скрипты не включаем
|
||||
public void Discard()
|
||||
{
|
||||
finished = true;
|
||||
All.Remove(this);
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
if (!finished && !All.Contains(this)) All.Add(this);
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
All.Remove(this);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!finished && GameClock.UnixNowMs >= endMs && (MayComplete == null || MayComplete(endMs))) Complete();
|
||||
}
|
||||
|
||||
// ---------------- Здание, которое строится, не работает ----------------
|
||||
|
||||
void PauseBuilding()
|
||||
{
|
||||
foreach (var mb in GetComponents<MonoBehaviour>())
|
||||
{
|
||||
if (mb == null || mb == this || !mb.enabled) continue;
|
||||
if (mb is PlacedBuildingId || mb is BuildingSlotLink) continue;
|
||||
mb.enabled = false;
|
||||
paused.Add(mb);
|
||||
}
|
||||
foreach (var sr in GetComponentsInChildren<SpriteRenderer>(true))
|
||||
{
|
||||
tinted.Add(new KeyValuePair<SpriteRenderer, Color>(sr, sr.color));
|
||||
var c = sr.color;
|
||||
sr.color = new Color(c.r * 0.7f, c.g * 0.7f, c.b * 0.7f, c.a * 0.55f);
|
||||
}
|
||||
}
|
||||
|
||||
void ResumeBuilding()
|
||||
{
|
||||
foreach (var mb in paused) if (mb != null) mb.enabled = true;
|
||||
paused.Clear();
|
||||
foreach (var kv in tinted) if (kv.Key != null) kv.Key.color = kv.Value;
|
||||
tinted.Clear();
|
||||
}
|
||||
|
||||
// ---------------- Тап по зданию, которое строится ----------------
|
||||
|
||||
void OnMouseDown()
|
||||
{
|
||||
downPos = Input.mousePosition;
|
||||
}
|
||||
|
||||
void OnMouseUpAsButton()
|
||||
{
|
||||
if (kind != Kind.Build || finished) return; // при улучшении окно открывает само здание
|
||||
if (UIBlocker.IsPointerOverUI()) return;
|
||||
if ((Input.mousePosition - downPos).magnitude > 25f) return;
|
||||
Tapped?.Invoke(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbfaba8641b05a9499eabe36c804fb22
|
||||
@@ -16,6 +16,11 @@ public static class SettlementModule
|
||||
public float posX;
|
||||
public float posY;
|
||||
public int level;
|
||||
// Стройка или улучшение, которое ещё идёт (01.11): 0 -- нет, 1 -- постройка, 2 -- улучшение
|
||||
public int timerKind;
|
||||
public long timerStartMs;
|
||||
public long timerEndMs;
|
||||
public int timerTargetLevel;
|
||||
}
|
||||
|
||||
// Имена полей совпадают со старым save.json -- старые сохранения читаются без переделки
|
||||
@@ -23,6 +28,10 @@ public static class SettlementModule
|
||||
public class SettlementSaveData
|
||||
{
|
||||
public int townHallLevel = 1;
|
||||
// Улучшение Ратуши, которое ещё идёт (01.11): timerEndMs 0 -- нет
|
||||
public long townHallTimerStartMs;
|
||||
public long townHallTimerEndMs;
|
||||
public int townHallTimerTargetLevel;
|
||||
public List<PlacedBuildingSaveEntry> placedBuildings = new List<PlacedBuildingSaveEntry>();
|
||||
}
|
||||
|
||||
@@ -46,18 +55,34 @@ public static class SettlementModule
|
||||
static SettlementSaveData CaptureScene()
|
||||
{
|
||||
var data = new SettlementSaveData();
|
||||
if (TownHall.Instance != null) data.townHallLevel = TownHall.Instance.currentLevel;
|
||||
if (TownHall.Instance != null)
|
||||
{
|
||||
data.townHallLevel = TownHall.Instance.currentLevel;
|
||||
var thTimer = BuildTimer.Of(TownHall.Instance.gameObject);
|
||||
if (thTimer != null && BuildTimer.All.Contains(thTimer))
|
||||
{
|
||||
data.townHallTimerStartMs = thTimer.startMs;
|
||||
data.townHallTimerEndMs = thTimer.endMs;
|
||||
data.townHallTimerTargetLevel = thTimer.targetLevel;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var placed in UnityEngine.Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
|
||||
{
|
||||
if (string.IsNullOrEmpty(placed.buildingName)) continue; // уже убрано, Destroy сработает в конце кадра
|
||||
var leveled = placed.GetComponent<ILeveledBuilding>();
|
||||
var timer = placed.GetComponent<BuildTimer>();
|
||||
bool timerOn = timer != null && BuildTimer.All.Contains(timer);
|
||||
data.placedBuildings.Add(new PlacedBuildingSaveEntry
|
||||
{
|
||||
buildingName = placed.buildingName,
|
||||
posX = placed.transform.position.x,
|
||||
posY = placed.transform.position.y,
|
||||
level = leveled != null ? leveled.Level : 1
|
||||
level = leveled != null ? leveled.Level : 1,
|
||||
timerKind = timerOn ? (int)timer.kind : 0,
|
||||
timerStartMs = timerOn ? timer.startMs : 0,
|
||||
timerEndMs = timerOn ? timer.endMs : 0,
|
||||
timerTargetLevel = timerOn ? timer.targetLevel : 0
|
||||
});
|
||||
}
|
||||
return data;
|
||||
@@ -74,7 +99,16 @@ public static class SettlementModule
|
||||
|
||||
EventBus.Publish(new SettlementRestoringEvent()); // например, Казармы должны попасть в список стройки
|
||||
|
||||
if (TownHall.Instance != null) TownHall.Instance.currentLevel = cached.townHallLevel;
|
||||
if (TownHall.Instance != null)
|
||||
{
|
||||
TownHall.Instance.currentLevel = cached.townHallLevel;
|
||||
var oldTimer = BuildTimer.Of(TownHall.Instance.gameObject);
|
||||
if (oldTimer != null) oldTimer.Cancel();
|
||||
// Проверка таймера из файла: улучшение до уровня выше текущего, конец не раньше начала
|
||||
if (cached.townHallTimerEndMs > 0 && cached.townHallTimerTargetLevel > cached.townHallLevel)
|
||||
BuildTimer.Begin(TownHall.Instance.gameObject, BuildTimer.Kind.Upgrade, cached.townHallTimerTargetLevel,
|
||||
cached.townHallTimerStartMs, cached.townHallTimerEndMs);
|
||||
}
|
||||
|
||||
// Сначала убираем уже заспавненные здания, чтобы не задвоить постройки
|
||||
ClearPlacedBuildings();
|
||||
@@ -98,6 +132,14 @@ public static class SettlementModule
|
||||
var leveled = built.GetComponent<ILeveledBuilding>();
|
||||
if (leveled != null) leveled.Level = entry.level;
|
||||
|
||||
// Стройка или улучшение продолжается (если время уже вышло -- закончится в первом же кадре)
|
||||
// Проверка таймера из файла: постройка -- только для 1 уровня, улучшение -- только до уровня выше текущего
|
||||
bool validBuild = entry.timerKind == (int)BuildTimer.Kind.Build && entry.level <= 1;
|
||||
bool validUpgrade = entry.timerKind == (int)BuildTimer.Kind.Upgrade && entry.timerTargetLevel > Mathf.Max(1, entry.level);
|
||||
if ((validBuild || validUpgrade) && entry.timerEndMs > 0)
|
||||
BuildTimer.Begin(built, (BuildTimer.Kind)entry.timerKind, Mathf.Max(1, entry.timerTargetLevel),
|
||||
entry.timerStartMs, entry.timerEndMs);
|
||||
|
||||
// Помечаем слот занятым, чтобы поверх нельзя было поставить ещё одно здание
|
||||
BuildSlot slot = BuildManager.Instance.FindSlotAtPosition(pos);
|
||||
if (slot != null)
|
||||
@@ -124,6 +166,8 @@ public static class SettlementModule
|
||||
link.linkedSlot = null;
|
||||
}
|
||||
e.buildingName = null; // чтобы сохранение в этом же кадре уже не видело здание
|
||||
var timer = e.GetComponent<BuildTimer>();
|
||||
if (timer != null) timer.Discard(); // строитель свободен сразу, а не в конце кадра
|
||||
UnityEngine.Object.Destroy(e.gameObject);
|
||||
}
|
||||
}
|
||||
@@ -167,7 +211,12 @@ public static class SettlementModule
|
||||
if (!CityLoaded) return;
|
||||
|
||||
ClearPlacedBuildings();
|
||||
if (TownHall.Instance != null) TownHall.Instance.currentLevel = 1;
|
||||
if (TownHall.Instance != null)
|
||||
{
|
||||
TownHall.Instance.currentLevel = 1;
|
||||
var thTimer = BuildTimer.Of(TownHall.Instance.gameObject);
|
||||
if (thTimer != null) thTimer.Cancel();
|
||||
}
|
||||
appliedToBuildManager = BuildManager.Instance; // город уже в состоянии новой игры
|
||||
EventBus.Publish(new TownHallLevelChangedEvent { Level = 1 });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"Bogatyri.Battle",
|
||||
"Bogatyri.World.Simulation",
|
||||
"Bogatyri.Chat",
|
||||
"Bogatyri.Circle"
|
||||
"Bogatyri.Circle",
|
||||
"Bogatyri.Intelligence"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -456,7 +456,7 @@ public class PopulationTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClockGoesBack_NothingHappens_ThenContinues()
|
||||
public void ClockGoesBack_NothingHappens_CountedTimeNotCountedAgain()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 1000);
|
||||
var p = StartCity();
|
||||
@@ -465,8 +465,12 @@ public class PopulationTests
|
||||
Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "время назад -- ничего не съедено");
|
||||
Assert.AreEqual(300, p.Total);
|
||||
|
||||
// Решения 04.11: метка последнего расчёта не уходит назад -- уже посчитанное время второй раз не считается
|
||||
p.Tick(T0 - 5000 + Hour);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01);
|
||||
Assert.AreEqual(1000, economy.Get(ResourceType.Food), 0.0001, "часы ещё не дошли до уже посчитанного -- ничего");
|
||||
|
||||
p.Tick(T0 + Hour);
|
||||
Assert.AreEqual(1000 - 33, economy.Get(ResourceType.Food), 0.01, "дальше метки -- ровно один час");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -245,4 +245,96 @@ public class ProductionTests
|
||||
Assert.AreEqual(40, copy.Slots(WorkJob.Stonecutter));
|
||||
Assert.AreEqual(1.1f, copy.Efficiency(WorkJob.Stonecutter), 0.0001f);
|
||||
}
|
||||
|
||||
// ---------------- Офлайн по временным меткам (решения 04.11) ----------------
|
||||
// Стройка, закончившаяся в офлайне, вступает в силу в момент расчёта, когда время дошло до её конца
|
||||
// (GameTimeSimulatedEvent). В игре это делает App/ConstructionClock; здесь -- подписка в тесте.
|
||||
|
||||
void CompleteUpgradeAt(long atUnix, List<KeyValuePair<WorkJob, int>> after)
|
||||
{
|
||||
bool done = false;
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(e =>
|
||||
{
|
||||
if (done || e.UnixNow < atUnix) return;
|
||||
done = true;
|
||||
production.SetBuildings(after);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Offline_UpgradeFinishedMidway_ProducesNewRateOnlyFromCompletion()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
economy.Set(ResourceType.Wood, 0);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30); // 60 дерева в час, на 2 уровне -- 66
|
||||
CompleteUpgradeAt(T0 + 2 * Hour, Buildings((WorkJob.Woodcutter, 2)));
|
||||
|
||||
population.Tick(T0 + 4 * Hour); // 4 часа офлайна, улучшение закончилось через 2 часа
|
||||
|
||||
Assert.AreEqual(2 * 60 + 2 * 66, economy.Get(ResourceType.Wood), 0.01f,
|
||||
"2 часа по 60 + 2 часа по 66, а не все 4 часа по новой норме");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Offline_FinishedInsideStep_CountsFromNextStep_NeverEarlier()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
economy.Set(ResourceType.Wood, 0);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
CompleteUpgradeAt(T0 + 2 * Hour + 5 * 60, Buildings((WorkJob.Woodcutter, 2))); // посреди 10-минутного шага
|
||||
|
||||
population.Tick(T0 + 4 * Hour);
|
||||
|
||||
float wood = economy.Get(ResourceType.Wood);
|
||||
Assert.LessOrEqual(wood, 125 / 60f * 60 + 115 / 60f * 66 + 0.01f, "не больше, чем если бы новая норма шла ровно с конца стройки");
|
||||
Assert.AreEqual(130 / 60f * 60 + 110 / 60f * 66, wood, 0.01f, "с ближайшего шага расчёта после конца стройки");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Tick_SameMomentTwice_NoExtraResources()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
population.Tick(T0 + Hour);
|
||||
float wood = economy.Get(ResourceType.Wood);
|
||||
|
||||
population.Tick(T0 + Hour);
|
||||
population.Tick(T0 + Hour - 60); // часы ушли назад -- ничего не начисляется
|
||||
population.Tick(T0 + Hour);
|
||||
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Restore_SameMoment_NoSecondCredit()
|
||||
{
|
||||
economy.Set(ResourceType.Food, 100000);
|
||||
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1)));
|
||||
population.SetWorkers(WorkJob.Woodcutter, 30);
|
||||
population.Tick(T0 + Hour);
|
||||
float wood = economy.Get(ResourceType.Wood);
|
||||
|
||||
var copy = new PopulationService(new PopulationRules());
|
||||
copy.Restore(population.Capture()); // как загрузка сохранения: метка последнего расчёта та же
|
||||
GameServices.Register<IPopulation>(copy);
|
||||
copy.Tick(T0 + Hour);
|
||||
Assert.AreEqual(wood, economy.Get(ResourceType.Wood), 0.001f, "загрузка не начисляет то же время второй раз");
|
||||
|
||||
copy.Tick(T0 + 2 * Hour);
|
||||
Assert.AreEqual(wood + 60, economy.Get(ResourceType.Wood), 0.01f, "а следующий час -- ровно один раз");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SimulatedEvents_GoForwardStepByStep_EndAtNow()
|
||||
{
|
||||
var times = new List<long>();
|
||||
EventBus.Subscribe<GameTimeSimulatedEvent>(e => times.Add(e.UnixNow));
|
||||
population.Tick(T0 + Hour);
|
||||
|
||||
Assert.AreEqual(T0 + Hour, times[times.Count - 1], "последнее событие -- момент Tick");
|
||||
for (int i = 1; i < times.Count; i++) Assert.GreaterOrEqual(times[i], times[i - 1], "время расчёта не идёт назад");
|
||||
Assert.AreEqual(7, times.Count, "6 шагов по 10 минут + конец");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ using UnityEngine;
|
||||
// бой -> IBattleSystem (передаётся в WorldSimulation)
|
||||
// сохранение -> сам ISaveParticipant, секция "world"
|
||||
// наружу -> MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent
|
||||
// разведка -> сам IWorldMap (где армии в момент t), WorldAdvancingEvent перед каждым расчётом
|
||||
//
|
||||
// Точка расширения под мультиплеер: сейчас WorldSimulation крутится прямо здесь.
|
||||
// Позже TryStartMarch будет отправлять запрос на сервер, а походы и их итоги -- приходить с сервера.
|
||||
public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
{
|
||||
public const string LocalPlayerId = LocalPlayer.Id;
|
||||
|
||||
@@ -26,6 +27,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
public event Action<MarchData> OnMarchStarted;
|
||||
public event Action<MarchData> OnBattleResolved;
|
||||
public event Action<MarchData> OnMarchReturned;
|
||||
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
|
||||
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой
|
||||
|
||||
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
|
||||
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
|
||||
@@ -35,6 +38,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
private WorldSimulation world;
|
||||
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
|
||||
private List<CampState> pendingCamps;
|
||||
private ResourcePointsState pendingPoints;
|
||||
private PatrolState pendingPatrols;
|
||||
|
||||
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
|
||||
public WorldSimulation World
|
||||
@@ -43,17 +48,29 @@ 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;
|
||||
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>(), SpeedRules, PointRules);
|
||||
world.AttackerConditions = m => m.ownerId == LocalPlayerId ? PlayerConditionsAt(m.arriveMs / 1000) : BattleCondition.None;
|
||||
world.Patrols = Patrols;
|
||||
world.LoadPatrolState(pendingPatrols);
|
||||
pendingPatrols = null;
|
||||
if (pendingPoints != null) world.LoadPointsState(pendingPoints); // точки -- до походов: поход к точке ищет её
|
||||
if (pendingMarches != null) world.LoadMarches(pendingMarches);
|
||||
if (pendingCamps != null) world.LoadCampStates(pendingCamps);
|
||||
pendingMarches = null;
|
||||
pendingCamps = null;
|
||||
pendingPoints = null;
|
||||
}
|
||||
return world;
|
||||
}
|
||||
}
|
||||
|
||||
// Скорости маршей из таблицы MarchConfig -- их задаёт App/GameModules при запуске (модуль таблицу не видит)
|
||||
public static MarchSpeedRules SpeedRules = MarchSpeedRules.Default;
|
||||
// Числа ресурсных точек из таблицы WorldResourceConfig -- тоже задаёт App/GameModules
|
||||
public static ResourcePointRules PointRules = ResourcePointRules.Default;
|
||||
// Патрули-заглушки соседей (Разведка М1) из таблицы MarchConfig -- тоже задаёт App/GameModules
|
||||
public static PatrolRules Patrols = PatrolRules.Default;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
static void CreateInstance()
|
||||
{
|
||||
@@ -66,6 +83,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
{
|
||||
Instance = this;
|
||||
GameServices.Register<IWorld>(this);
|
||||
GameServices.Register<IWorldMap>(this);
|
||||
SaveRegistry.Register(this);
|
||||
}
|
||||
|
||||
@@ -73,6 +91,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
{
|
||||
if (Instance != this) return;
|
||||
GameServices.Unregister<IWorld>(this);
|
||||
GameServices.Unregister<IWorldMap>(this);
|
||||
SaveRegistry.Unregister(this);
|
||||
}
|
||||
|
||||
@@ -81,20 +100,27 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
// Без армии и экономики некуда вернуть войско и добычу -- ждём, пока они появятся
|
||||
if (!GameServices.Has<IArmy>() || !GameServices.Has<IEconomy>()) return;
|
||||
|
||||
long now = NowMs;
|
||||
CatchUpPopulationBeforeBattles(now);
|
||||
// Мир досчитывается по событиям, перед каждым шагом Разведка досчитывает наблюдение до него:
|
||||
// походы ещё на карте и их выжившие уже известны (после офлайна обратный путь тоже виден)
|
||||
events.Clear();
|
||||
World.Advance(NowMs, events);
|
||||
World.AdvanceInSteps(now, events, t => EventBus.Publish(new WorldAdvancingEvent { ToMs = t }));
|
||||
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (e.Type == WorldEventType.BattleResolved) HandleBattleResolved(e.March);
|
||||
else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March);
|
||||
else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March);
|
||||
else if (e.Type == WorldEventType.ScoutArrived) HandleScoutArrived(e.March);
|
||||
else if (e.Type == WorldEventType.GatherFinished)
|
||||
Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой");
|
||||
}
|
||||
|
||||
if (events.Count > 0 && GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
||||
}
|
||||
|
||||
// Состояние войск игрока сейчас: голод -- из модуля Населения (план от 27.10.2026: проверяется на момент боя).
|
||||
// Бой, прошедший пока игра была закрыта, получает голод на момент входа: истории голода у Населения нет.
|
||||
// Состояние войск игрока сейчас: голод -- из модуля Населения. Для окна атаки (прогноз).
|
||||
// Усталость пока не выставляется -- правила её учёта не утверждены.
|
||||
public static BattleCondition PlayerConditions()
|
||||
{
|
||||
@@ -103,11 +129,72 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
: BattleCondition.None;
|
||||
}
|
||||
|
||||
// Состояние войск игрока в момент боя (решение №9: голод проверяется на момент боя).
|
||||
// Бой, прошедший пока игра была закрыта, спрашивает историю голода модуля Населения (WasStarvingAt).
|
||||
static BattleCondition PlayerConditionsAt(long unixTime)
|
||||
{
|
||||
return GameServices.TryGet(out IPopulation population) && population.WasStarvingAt(unixTime)
|
||||
? BattleCondition.Famine
|
||||
: BattleCondition.None;
|
||||
}
|
||||
|
||||
// Офлайн -- в причинно-следственном порядке (документ 02): прежде чем считать бой, прошедший в прошлом,
|
||||
// просим Население досчитать время до "сейчас" -- иначе в его истории ещё нет голода за эти часы.
|
||||
// Tick -- публичный метод Населения, тот же, что зовёт PopulationTicker; сам модуль не меняется.
|
||||
void CatchUpPopulationBeforeBattles(long nowMs)
|
||||
{
|
||||
bool battleDue = false;
|
||||
foreach (var m in World.Marches)
|
||||
if (m.ownerId == LocalPlayerId && m.state == MarchState.Outbound && nowMs >= m.arriveMs) { battleDue = true; break; }
|
||||
if (battleDue && GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow);
|
||||
}
|
||||
|
||||
// ---------------- Контракт IWorld ----------------
|
||||
|
||||
public int ActiveMarches => World.Marches.Count;
|
||||
public int ActiveMarches
|
||||
{
|
||||
get { int n = 0; foreach (var m in World.Marches) if (m.ownerId == LocalPlayerId) n++; return n; }
|
||||
}
|
||||
|
||||
// ---------------- Контракт IWorldMap (для Разведки) ----------------
|
||||
|
||||
public int HalfWidth => WorldMapLayout.HalfWidth;
|
||||
public int HalfHeight => WorldMapLayout.HalfHeight;
|
||||
|
||||
public bool TryGetSettlement(string ownerId, out int x, out int y)
|
||||
{
|
||||
foreach (var n in World.Nodes)
|
||||
if (n.ownerId == ownerId && (n.type == MapNodeType.PlayerCity || n.type == MapNodeType.NeighborCity))
|
||||
{
|
||||
x = n.x; y = n.y;
|
||||
return true;
|
||||
}
|
||||
x = y = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера)
|
||||
public void GetArmies(long timeMs, List<ArmyOnMap> result)
|
||||
{
|
||||
foreach (var m in World.Marches)
|
||||
{
|
||||
if (!World.TryGetPositionAt(m, timeMs, out int x, out int y, out int toX, out int toY, out int soldiers)) continue;
|
||||
result.Add(new ArmyOnMap { Id = "march_" + m.id, OwnerId = m.ownerId, X = x, Y = y, ToX = toX, ToY = toY, Soldiers = soldiers });
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryStartMarch(string targetId, int soldiers, out string error)
|
||||
{
|
||||
return TryStart(targetId, soldiers, false, out error);
|
||||
}
|
||||
|
||||
// Поход «Разведка» (решения 31.10, п. 2): тот же путь отправки и тот же слот, но без боя и сбора
|
||||
public bool TryStartScout(string targetId, int soldiers, out string error)
|
||||
{
|
||||
return TryStart(targetId, soldiers, true, out error);
|
||||
}
|
||||
|
||||
bool TryStart(string targetId, int soldiers, bool scout, out string error)
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army))
|
||||
{
|
||||
@@ -122,7 +209,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
return false;
|
||||
}
|
||||
|
||||
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
|
||||
// Слоты похода (регламент 31.10): база 2, «Военная логистика» -- позже
|
||||
if (army.ActiveMarches >= army.MarchSlots)
|
||||
{
|
||||
error = $"Все слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}). Дождитесь возвращения войска";
|
||||
return false;
|
||||
}
|
||||
|
||||
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
|
||||
if (march == null) return false;
|
||||
|
||||
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
|
||||
@@ -133,12 +227,13 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
error = "Не удалось собрать войско в поход";
|
||||
return false;
|
||||
}
|
||||
march.units = army.GetMarchUnits(march.id); // состав похода -- для боя с типами войск
|
||||
// Состав похода -- для боя с типами войск и для скорости: время прихода по средней скорости состава
|
||||
World.AssignUnits(march, army.GetMarchUnits(march.id));
|
||||
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
|
||||
|
||||
Debug.Log($"[ПОХОД] {soldiers} солдат выступили на {World.GetNode(targetId).displayName}");
|
||||
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}");
|
||||
OnMarchStarted?.Invoke(march);
|
||||
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TargetId = targetId, Soldiers = soldiers });
|
||||
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -152,6 +247,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
EventBus.Publish(new MarchBattleResolvedEvent
|
||||
{
|
||||
MarchId = march.id,
|
||||
BattleId = march.battleSeed,
|
||||
TimeMs = march.arriveMs,
|
||||
TargetId = march.targetNodeId,
|
||||
Victory = march.victory,
|
||||
Soldiers = march.soldiers,
|
||||
@@ -159,12 +256,29 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
});
|
||||
}
|
||||
|
||||
void HandleGatherStarted(MarchData march)
|
||||
{
|
||||
string target = World.TargetName(march);
|
||||
if (march.targetEmpty) Debug.Log($"[СБОР] {target}: точки уже нет или она занята -- войско возвращается");
|
||||
else Debug.Log($"[СБОР] {march.soldiers} воинов начали сбор: {target}, унесут {march.cargo}");
|
||||
OnGatherStarted?.Invoke(march);
|
||||
}
|
||||
|
||||
void HandleScoutArrived(MarchData march)
|
||||
{
|
||||
string target = World.TargetName(march);
|
||||
Debug.Log(march.targetEmpty
|
||||
? $"[РАЗВЕДКА] {target}: цели уже нет -- войско возвращается"
|
||||
: $"[РАЗВЕДКА] Войско дошло до цели: {target}, возвращается домой");
|
||||
OnScoutArrived?.Invoke(march);
|
||||
}
|
||||
|
||||
// Солдаты и добыча зачисляются, только когда войско дошло до дома
|
||||
void HandleMarchReturned(MarchData march)
|
||||
{
|
||||
IArmy army = GameServices.Get<IArmy>();
|
||||
bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count
|
||||
? army.ReturnUnits(march.id, march.survivorUnits) // бой посчитал потери по типам
|
||||
? army.ReturnUnits(march.id, march.survivorUnits, march.woundedUnits, march.returnMs) // по типам; раненые -- в госпиталь
|
||||
: army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции
|
||||
|
||||
// Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи
|
||||
@@ -188,11 +302,15 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
EventBus.Publish(new MarchReturnedEvent
|
||||
{
|
||||
MarchId = march.id,
|
||||
TimeMs = march.returnMs,
|
||||
Victory = march.victory,
|
||||
Survivors = march.survivors,
|
||||
Sent = march.soldiers,
|
||||
TargetEmpty = march.targetEmpty,
|
||||
TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "",
|
||||
IsGather = march.gather,
|
||||
IsScout = march.scout,
|
||||
Wounded = Sum(march.woundedUnits),
|
||||
TargetName = World.TargetName(march),
|
||||
LootText = FormatLoot(march.loot)
|
||||
});
|
||||
}
|
||||
@@ -204,6 +322,8 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
{
|
||||
public List<MarchData> marches = new List<MarchData>();
|
||||
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
|
||||
public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает
|
||||
public PatrolState patrols; // патрули-заглушки: когда следующий
|
||||
}
|
||||
|
||||
public string SaveKey => "world";
|
||||
@@ -211,15 +331,22 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
public string Capture()
|
||||
{
|
||||
var camps = world != null ? world.GetCampStates() : new List<CampState>(pendingCamps ?? new List<CampState>());
|
||||
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps });
|
||||
var points = world != null ? world.GetPointsState() : pendingPoints;
|
||||
var patrols = world != null ? world.GetPatrolState() : pendingPatrols;
|
||||
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave(), camps = camps, points = points, patrols = patrols });
|
||||
}
|
||||
|
||||
public void Restore(string json)
|
||||
{
|
||||
WorldSaveData data = JsonUtility.FromJson<WorldSaveData>(json);
|
||||
// Точки -- до походов. Старое сохранение без точек: точки появятся при первом расчёте мира
|
||||
if (world != null) world.LoadPointsState(data?.points);
|
||||
else pendingPoints = data?.points;
|
||||
LoadMarches(data?.marches);
|
||||
if (world != null) world.LoadCampStates(data?.camps);
|
||||
else pendingCamps = data?.camps;
|
||||
if (world != null) world.LoadPatrolState(data?.patrols);
|
||||
else pendingPatrols = data?.patrols;
|
||||
}
|
||||
|
||||
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте
|
||||
@@ -227,10 +354,14 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
{
|
||||
pendingMarches = null;
|
||||
pendingCamps = null;
|
||||
pendingPoints = null;
|
||||
pendingPatrols = null;
|
||||
if (world != null)
|
||||
{
|
||||
world.LoadPatrolState(null);
|
||||
world.LoadMarches(new List<MarchData>());
|
||||
world.LoadCampStates(null);
|
||||
world.LoadPointsState(null); // ресурсные точки появятся заново
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +377,13 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
|
||||
else pendingMarches = saved;
|
||||
}
|
||||
|
||||
static int Sum(int[] units)
|
||||
{
|
||||
int n = 0;
|
||||
if (units != null) foreach (var u in units) n += Math.Max(0, u);
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------- ТЕКСТ ДЛЯ UI ----------------
|
||||
|
||||
public static string FormatLoot(List<RewardEntry> loot)
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace Bogatyri.World
|
||||
PlayerCity, // город игрока
|
||||
NeighborCity, // соседний город -- другой игрок
|
||||
BossCastle, // Большой замок с боссом в центре карты
|
||||
Camp // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова
|
||||
Camp, // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова
|
||||
ResourcePoint // ресурсная точка: войско собирает ресурс, точка истощается и исчезает (регламент 31.10)
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -184,7 +185,7 @@ namespace Bogatyri.World
|
||||
}
|
||||
|
||||
// Случайная точка, не слишком близко к замку и к другим точкам
|
||||
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y, int spacing = MinCitySpacing)
|
||||
public static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y, int spacing = MinCitySpacing)
|
||||
{
|
||||
for (int attempt = 0; attempt < MaxPlacementAttempts; attempt++)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Bogatyri.World
|
||||
{
|
||||
// ПАТРУЛИ-ЗАГЛУШКИ (Разведка М1, решение команды 31.10.2026). ВРЕМЕННАЯ ТЕСТОВАЯ МЕХАНИКА, а не ИИ соседей:
|
||||
// соседние поселения иногда отправляют отряд к другому городу или лагерю и обратно -- чтобы проверить
|
||||
// обнаружение, примерную численность, опознание владельца и потерю контакта.
|
||||
// Патруль -- обычный поход (MarchData.patrol): Разведка видит его через тот же IWorldMap, что и настоящие армии.
|
||||
// Боя, добычи и разбития лагеря нет, Армия игрока о патрулях не знает.
|
||||
// Когда появится настоящий PvP -- Enabled = false в таблице MarchConfig, система наблюдения не меняется.
|
||||
public sealed class PatrolRules
|
||||
{
|
||||
public bool Enabled = false; // в игре -- из таблицы MarchConfig (там включено для теста)
|
||||
public int MaxActive = 3; // сколько патрулей одновременно на карте
|
||||
public long IntervalMs = 10 * 60_000; // новый патруль -- раз в 10 РЕАЛЬНЫХ минут (если есть свободное место)
|
||||
public int SoldiersMin = 10;
|
||||
public int SoldiersMax = 40;
|
||||
public int NearestSources = 6; // патрули выходят из ближайших к игроку соседних городов -- чтобы их было видно
|
||||
public int NearestTargets = 6; // и идут к одной из ближайших к игроку целей (город или лагерь)
|
||||
public long MaxCatchUpMs = 48L * 3_600_000; // после долгого офлайна досчитываются только последние 48 часов
|
||||
|
||||
public static PatrolRules Default => new PatrolRules();
|
||||
}
|
||||
|
||||
// Состояние патрулей в сохранении мира
|
||||
[Serializable]
|
||||
public class PatrolState
|
||||
{
|
||||
public long nextMs; // когда выйдет следующий патруль
|
||||
public int count; // сколько патрулей уже вышло (номер броска)
|
||||
}
|
||||
|
||||
public sealed partial class WorldSimulation
|
||||
{
|
||||
private PatrolRules patrolRules = PatrolRules.Default;
|
||||
private long patrolNextMs;
|
||||
private int patrolCount;
|
||||
|
||||
public PatrolRules Patrols
|
||||
{
|
||||
get => patrolRules;
|
||||
set => patrolRules = value ?? PatrolRules.Default;
|
||||
}
|
||||
|
||||
public PatrolState GetPatrolState() => new PatrolState { nextMs = patrolNextMs, count = patrolCount };
|
||||
|
||||
public void LoadPatrolState(PatrolState s)
|
||||
{
|
||||
patrolNextMs = s?.nextMs ?? 0;
|
||||
patrolCount = s?.count ?? 0;
|
||||
}
|
||||
|
||||
// Выпустить патрули, которым пора выйти к nowMs (каждый -- со своим временем выхода).
|
||||
// Можно звать много раз: уже вышедшие не повторяются. Выключено -- патрули убираются с карты.
|
||||
public void SpawnPatrolsUntil(long nowMs)
|
||||
{
|
||||
if (!patrolRules.Enabled)
|
||||
{
|
||||
marches.RemoveAll(m => m.patrol);
|
||||
return;
|
||||
}
|
||||
long interval = Math.Max(60_000, patrolRules.IntervalMs);
|
||||
if (patrolNextMs == 0 || nowMs - patrolNextMs > patrolRules.MaxCatchUpMs)
|
||||
patrolNextMs = patrolNextMs == 0 ? nowMs : nowMs - patrolRules.MaxCatchUpMs;
|
||||
|
||||
for (; patrolNextMs <= nowMs; patrolNextMs += interval)
|
||||
{
|
||||
long t = patrolNextMs;
|
||||
int active = 0;
|
||||
foreach (var m in marches) if (m.patrol && m.departMs <= t && m.returnMs > t) active++;
|
||||
if (active >= patrolRules.MaxActive) continue;
|
||||
SpawnPatrol(t);
|
||||
}
|
||||
}
|
||||
|
||||
void SpawnPatrol(long t)
|
||||
{
|
||||
var rng = new DeterministicRandom(pointSeed ^ 0x7A3201 ^ patrolCount * 104729);
|
||||
patrolCount++;
|
||||
|
||||
MapNode home = GetNode(WorldMapLayout.PlayerCityId);
|
||||
var cities = new List<MapNode>();
|
||||
foreach (var n in nodes) if (n.type == MapNodeType.NeighborCity) cities.Add(n);
|
||||
if (cities.Count == 0) return;
|
||||
if (home != null) cities.Sort((a, b) => Dist(a, home).CompareTo(Dist(b, home)));
|
||||
MapNode from = cities[rng.Range(0, Math.Min(Math.Max(1, patrolRules.NearestSources), cities.Count))];
|
||||
|
||||
var targets = new List<MapNode>();
|
||||
foreach (var n in nodes)
|
||||
if (n != from && (n.type == MapNodeType.NeighborCity || n.type == MapNodeType.Camp)) targets.Add(n);
|
||||
if (targets.Count == 0) return;
|
||||
// Цели -- тоже рядом с игроком: путь патруля проходит через его обзор (иначе проверять нечего)
|
||||
MapNode center = home ?? from;
|
||||
targets.Sort((a, b) => Dist(a, center).CompareTo(Dist(b, center)));
|
||||
MapNode to = targets[rng.Range(0, Math.Min(Math.Max(1, patrolRules.NearestTargets), targets.Count))];
|
||||
|
||||
int lo = Math.Max(1, patrolRules.SoldiersMin), hi = Math.Max(lo, patrolRules.SoldiersMax);
|
||||
int soldiers = rng.Range(lo, hi + 1);
|
||||
long travel = TravelTimeMs(from, to, null);
|
||||
marches.Add(new MarchData
|
||||
{
|
||||
id = nextMarchId++,
|
||||
ownerId = from.ownerId,
|
||||
fromNodeId = from.id,
|
||||
targetNodeId = to.id,
|
||||
targetName = to.displayName,
|
||||
soldiers = soldiers,
|
||||
survivors = soldiers,
|
||||
departMs = t,
|
||||
arriveMs = t + travel,
|
||||
returnMs = t + 2 * travel, // боя нет -- всё время пути известно сразу
|
||||
state = MarchState.Outbound,
|
||||
patrol = true
|
||||
});
|
||||
}
|
||||
|
||||
// Продвинуть мир до nowMs по событиям: перед каждым шагом beforeStep(момент) -- Разведка досчитывает
|
||||
// наблюдение до этого момента. Так после офлайна обратный путь войска считается уже после боя
|
||||
// (выжившие известны), а не как "стоят у цели". Итог тот же, что у одного Advance(nowMs).
|
||||
public void AdvanceInSteps(long nowMs, List<WorldEvent> events, Action<long> beforeStep)
|
||||
{
|
||||
SpawnPatrolsUntil(nowMs);
|
||||
long next;
|
||||
while ((next = NextEventMs(nowMs)) >= 0)
|
||||
{
|
||||
beforeStep?.Invoke(next);
|
||||
Advance(next, events);
|
||||
}
|
||||
beforeStep?.Invoke(nowMs);
|
||||
Advance(nowMs, events);
|
||||
}
|
||||
|
||||
// Ближайшее событие мира не позже nowMs (приход, конец сбора, исчезновение точки, возвращение); -1 -- нет
|
||||
public long NextEventMs(long nowMs)
|
||||
{
|
||||
long best = -1;
|
||||
foreach (var m in marches)
|
||||
{
|
||||
long at = m.state == MarchState.Outbound ? m.arriveMs
|
||||
: m.state == MarchState.Gathering ? m.gatherEndMs
|
||||
: m.state == MarchState.Returning ? m.returnMs : long.MaxValue;
|
||||
if (at <= nowMs && (best < 0 || at < best)) best = at;
|
||||
}
|
||||
foreach (var p in points)
|
||||
if (p.active && p.occupiedBy == 0 && p.expireMs <= nowMs && (best < 0 || p.expireMs < best)) best = p.expireMs;
|
||||
return best;
|
||||
}
|
||||
|
||||
static long Dist(MapNode a, MapNode b) => WorldMapLayout.Distance(a, b);
|
||||
|
||||
// Где войско в момент t (Unix мс) -- по времени похода, а не по текущему состоянию:
|
||||
// Разведка досчитывает наблюдение за прошедшие минуты. false -- ещё не вышло или уже дома.
|
||||
// x, y -- позиция, toX, toY -- куда идёт; soldiers -- сколько воинов в этот момент.
|
||||
public bool TryGetPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers)
|
||||
{
|
||||
x = y = toX = toY = soldiers = 0;
|
||||
MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId);
|
||||
if (home == null || target == null || t < m.departMs) return false;
|
||||
|
||||
bool resolved = m.state == MarchState.Returning || m.state == MarchState.Finished
|
||||
|| m.state == MarchState.Gathering || m.patrol;
|
||||
if (t < m.arriveMs || !resolved && m.state == MarchState.Outbound && t >= m.arriveMs)
|
||||
{
|
||||
// В пути к цели (или дошли, а мир ещё не посчитал приход -- стоят у цели)
|
||||
Lerp(home, target, m.departMs, m.arriveMs, Math.Min(t, m.arriveMs), out x, out y);
|
||||
toX = target.x; toY = target.y;
|
||||
soldiers = m.soldiers;
|
||||
return true;
|
||||
}
|
||||
|
||||
long backStart = m.gather ? m.gatherEndMs : m.arriveMs;
|
||||
soldiers = m.state == MarchState.Gathering || m.gather && t < backStart ? m.soldiers : m.survivors;
|
||||
if (soldiers <= 0) return false; // все пали в бою -- армии на карте нет
|
||||
if (m.state == MarchState.Gathering || t < backStart)
|
||||
{
|
||||
x = target.x; y = target.y; toX = target.x; toY = target.y; // стоят на точке и собирают
|
||||
return true;
|
||||
}
|
||||
if (m.returnMs > 0 && t >= m.returnMs) return false; // дома
|
||||
Lerp(target, home, backStart, m.returnMs, t, out x, out y);
|
||||
toX = home.x; toY = home.y;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void Lerp(MapNode a, MapNode b, long start, long end, long t, out int x, out int y)
|
||||
{
|
||||
if (end <= start) { x = b.x; y = b.y; return; }
|
||||
long p = Math.Max(0, Math.Min(end - start, t - start));
|
||||
x = a.x + (int)((long)(b.x - a.x) * p / (end - start));
|
||||
y = a.y + (int)((long)(b.y - a.y) * p / (end - start));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e64d4c94b1549d2815ee0cb278e0ac5
|
||||
@@ -0,0 +1,318 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Bogatyri.World
|
||||
{
|
||||
// Ресурсная точка на карте (регламент 31.10, раздел 7; решение №46: состояние точки ведёт World).
|
||||
// Состояние меняется со временем, поэтому хранится в сохранении, а не строится из seed, как замок и города.
|
||||
[Serializable]
|
||||
public class ResourcePoint
|
||||
{
|
||||
public string id;
|
||||
public int kind; // ResourcePointRules.Resources: 0 дерево, 1 камень, 2 металл, 3 золото
|
||||
public int size; // 0 малая, 1 средняя, 2 крупная
|
||||
public int x;
|
||||
public int y;
|
||||
public int stock; // сколько осталось
|
||||
public int stockMax;
|
||||
public long spawnMs; // появилась (реальное время часов GameClock)
|
||||
public long expireMs; // исчезнет
|
||||
public int occupiedBy; // номер похода, который сейчас собирает (0 -- свободна)
|
||||
public bool active; // false -- истощена или исчезла (остаётся, пока к ней идёт или с неё возвращается войско)
|
||||
}
|
||||
|
||||
// Числа ресурсных точек (регламент 31.10, раздел 7, ТЕСТ). В игре -- из таблицы WorldResourceConfig.
|
||||
public sealed class ResourcePointRules
|
||||
{
|
||||
// Имена ресурсов -- как в ResourceType (Мир про экономику не знает, зачисляет её модуль по имени)
|
||||
public static readonly string[] Resources = { "Wood", "Stone", "Iron", "Gold" };
|
||||
public static readonly string[] KindNames = { "Лес", "Каменоломня", "Рудник", "Золотая жила" };
|
||||
public static readonly string[] SizeNames = { "малая", "средняя", "крупная" };
|
||||
|
||||
// Запас: [ресурс][размер] -- дерево 200/600/1500, камень 150/450/1000, металл 80/250/600, золото 30/100/250
|
||||
public int[][] Stock =
|
||||
{
|
||||
new[] { 200, 600, 1500 },
|
||||
new[] { 150, 450, 1000 },
|
||||
new[] { 80, 250, 600 },
|
||||
new[] { 30, 100, 250 }
|
||||
};
|
||||
// Добыча одного воина за ИГРОВОЙ час, в тысячных долях: 2 / 1,5 / 1 / 0,5
|
||||
public int[] MiningMilliPerGameHour = { 2000, 1500, 1000, 500 };
|
||||
// Груз одного воина: 12 / 10 / 8 / 5. Осадные машины груза не несут (регламент)
|
||||
public int[] CargoPerSoldier = { 12, 10, 8, 5 };
|
||||
// Срок жизни точки в РЕАЛЬНЫХ часах с момента появления: малая 12, средняя 24, крупная 48
|
||||
public int[] LifetimeRealHours = { 12, 24, 48 };
|
||||
// 1 игровой час = 5 реальных минут (регламент 31.10, раздел 2)
|
||||
public long GameHourMs = 5 * 60 * 1000;
|
||||
|
||||
// Не задано регламентом -- ТЕСТ, решает команда:
|
||||
// Сколько точек одновременно на карте: [ресурс][размер] (решения по 8 вопросам Саши, 31.10, п. 6, ТЕСТ).
|
||||
// Всего 24: лес 8, камень 7, металл 5, золото 4; по размерам 12 малых, 8 средних, 4 крупных.
|
||||
// Раскладка размеров по ресурсам документом не задана -- задаётся здесь (в игре -- WorldResourceConfig).
|
||||
public int[][] Count =
|
||||
{
|
||||
new[] { 4, 3, 1 }, // лес 8
|
||||
new[] { 4, 2, 1 }, // камень 7
|
||||
new[] { 2, 2, 1 }, // металл 5
|
||||
new[] { 2, 1, 1 } // золото 4
|
||||
};
|
||||
public int Spacing = 2200; // не ближе 2,2 клетки к другим точкам карты
|
||||
|
||||
public static ResourcePointRules Default => new ResourcePointRules();
|
||||
|
||||
// Сбор с начала startMs: сколько войско унесёт и когда закончит.
|
||||
// Конец -- груз полон, точка истощена или исчезла (expireMs), что раньше.
|
||||
public void Gather(int kind, int[] units, int stock, long startMs, long expireMs, out int amount, out long endMs)
|
||||
{
|
||||
int miners = 0, carriers = 0;
|
||||
for (int i = 0; units != null && i < units.Length; i++)
|
||||
{
|
||||
if (units[i] <= 0) continue;
|
||||
miners += units[i];
|
||||
if (i != TroopType.Siege) carriers += units[i];
|
||||
}
|
||||
long target = Math.Min(Math.Max(0, stock), (long)carriers * CargoPerSoldier[kind]);
|
||||
long rate = (long)miners * MiningMilliPerGameHour[kind]; // тысячных долей за игровой час
|
||||
if (target <= 0 || rate <= 0 || expireMs <= startMs)
|
||||
{
|
||||
amount = 0;
|
||||
endMs = startMs;
|
||||
return;
|
||||
}
|
||||
long full = (target * 1000 * GameHourMs + rate - 1) / rate; // время до полного груза (вверх)
|
||||
if (startMs + full <= expireMs)
|
||||
{
|
||||
amount = (int)target;
|
||||
endMs = startMs + full;
|
||||
}
|
||||
else
|
||||
{
|
||||
endMs = expireMs; // точка исчезла раньше -- уносят, что успели
|
||||
amount = (int)Math.Min(target, rate * (expireMs - startMs) / (1000 * GameHourMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Состояние ресурсных точек для сохранения (секция "world")
|
||||
[Serializable]
|
||||
public class ResourcePointsState
|
||||
{
|
||||
public List<ResourcePoint> points = new List<ResourcePoint>();
|
||||
public int nextPointId = 1;
|
||||
public int generation; // сколько точек уже появилось -- для детерминированного места следующей
|
||||
public bool initialized; // первые точки уже выставлены
|
||||
}
|
||||
|
||||
public sealed partial class WorldSimulation
|
||||
{
|
||||
private readonly ResourcePointRules pointRules;
|
||||
private readonly int pointSeed;
|
||||
private readonly List<ResourcePoint> points = new List<ResourcePoint>();
|
||||
private int nextPointId = 1;
|
||||
private int pointGeneration;
|
||||
private bool pointsInitialized;
|
||||
|
||||
public ResourcePointRules PointRules => pointRules;
|
||||
public IReadOnlyList<ResourcePoint> ResourcePoints => points;
|
||||
|
||||
public ResourcePoint GetPoint(string id)
|
||||
{
|
||||
return points.Find(p => p.id == id);
|
||||
}
|
||||
|
||||
// Точки на карте по таблице Count. Первые -- в момент первого расчёта мира; после загрузки сохранения
|
||||
// недостающие добавляются (старое сохранение с 8 точками дополнится до 24, лишние доживут свой срок)
|
||||
void EnsureResourcePoints(long nowMs)
|
||||
{
|
||||
if (pointsInitialized && !pointsTopUp) return;
|
||||
pointsInitialized = true;
|
||||
pointsTopUp = false;
|
||||
SpawnMissingPoints(nowMs);
|
||||
}
|
||||
|
||||
private bool pointsTopUp;
|
||||
|
||||
// Сколько активных точек каждого ресурса и размера не хватает до таблицы -- столько и появляется
|
||||
void SpawnMissingPoints(long nowMs)
|
||||
{
|
||||
for (int kind = 0; kind < ResourcePointRules.Resources.Length; kind++)
|
||||
for (int size = 0; size < 3; size++)
|
||||
{
|
||||
int have = 0;
|
||||
foreach (var p in points) if (p.active && p.kind == kind && p.size == size) have++;
|
||||
for (int k = have; k < PointCount(kind, size); k++)
|
||||
if (SpawnPoint(kind, size, nowMs) == null) return; // карта переполнена -- попробуем при следующем исчезновении
|
||||
}
|
||||
}
|
||||
|
||||
int PointCount(int kind, int size)
|
||||
{
|
||||
var c = pointRules.Count;
|
||||
return c != null && kind < c.Length && c[kind] != null && size < c[kind].Length ? Math.Max(0, c[kind][size]) : 0;
|
||||
}
|
||||
|
||||
// Новая точка этого ресурса и размера в другом месте. Место -- из seed и номера появления (одинаково на сервере)
|
||||
ResourcePoint SpawnPoint(int kind, int size, long nowMs)
|
||||
{
|
||||
pointGeneration++;
|
||||
var rng = new DeterministicRandom(pointSeed ^ 0x2F0E1D ^ pointGeneration * 7919);
|
||||
if (!WorldMapLayout.TryFindFreeSpot(rng, nodes, out int x, out int y, pointRules.Spacing)) return null;
|
||||
|
||||
var p = new ResourcePoint
|
||||
{
|
||||
id = "res_" + nextPointId++,
|
||||
kind = kind,
|
||||
size = size,
|
||||
x = x,
|
||||
y = y,
|
||||
stock = pointRules.Stock[kind][size],
|
||||
stockMax = pointRules.Stock[kind][size],
|
||||
spawnMs = nowMs,
|
||||
expireMs = nowMs + pointRules.LifetimeRealHours[size] * 3_600_000L,
|
||||
active = true
|
||||
};
|
||||
points.Add(p);
|
||||
nodes.Add(ToNode(p));
|
||||
return p;
|
||||
}
|
||||
|
||||
static MapNode ToNode(ResourcePoint p)
|
||||
{
|
||||
return new MapNode
|
||||
{
|
||||
id = p.id,
|
||||
displayName = $"{ResourcePointRules.KindNames[p.kind]} ({ResourcePointRules.SizeNames[p.size]})",
|
||||
type = MapNodeType.ResourcePoint,
|
||||
x = p.x,
|
||||
y = p.y,
|
||||
canBeAttacked = true, // «можно отправить войско»
|
||||
level = p.size + 1
|
||||
};
|
||||
}
|
||||
|
||||
// Точка истощена или исчезла: на её месте больше нечего собирать, новая того же ресурса и размера
|
||||
// появляется в другом месте (если таких точек теперь меньше, чем в таблице)
|
||||
void RetirePoint(ResourcePoint p, long atMs)
|
||||
{
|
||||
if (!p.active) return;
|
||||
p.active = false;
|
||||
p.occupiedBy = 0;
|
||||
SpawnMissingPoints(atMs);
|
||||
}
|
||||
|
||||
// Исчезнувшие точки убираются с карты, когда к ним уже никто не идёт и с них никто не возвращается
|
||||
void PurgeRetiredPoints()
|
||||
{
|
||||
points.RemoveAll(p =>
|
||||
{
|
||||
if (p.active || marches.Exists(m => m.targetNodeId == p.id)) return false;
|
||||
nodes.RemoveAll(n => n.id == p.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public bool CanGatherAt(string pointId, long nowMs, out string error)
|
||||
{
|
||||
ResourcePoint p = GetPoint(pointId);
|
||||
if (p == null || !p.active || nowMs >= p.expireMs) { error = "Этой ресурсной точки больше нет"; return false; }
|
||||
if (p.occupiedBy != 0) { error = "Точку уже занимает войско"; return false; }
|
||||
if (marches.Exists(m => m.targetNodeId == pointId && m.state == MarchState.Outbound && m.gather)) // разведка не мешает
|
||||
{
|
||||
error = "К этой точке уже идёт войско";
|
||||
return false;
|
||||
}
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Прогноз сбора для окна отправки: сколько унесут и сколько продлится сбор, если дойдут в arriveMs
|
||||
public void PreviewGather(string pointId, int[] units, long arriveMs, out int amount, out long gatherMs)
|
||||
{
|
||||
ResourcePoint p = GetPoint(pointId);
|
||||
if (p == null) { amount = 0; gatherMs = 0; return; }
|
||||
pointRules.Gather(p.kind, units, p.stock, arriveMs, p.expireMs, out amount, out long endMs);
|
||||
gatherMs = endMs - arriveMs;
|
||||
}
|
||||
|
||||
// Войско дошло до точки: начинает сбор. Точки нет или она занята -- возвращается без добычи
|
||||
void StartGather(MarchData m)
|
||||
{
|
||||
MapNode from = GetNode(m.fromNodeId);
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
ResourcePoint p = GetPoint(m.targetNodeId);
|
||||
int[] units = HasUnits(m) ? m.units : new[] { m.soldiers, 0, 0, 0 };
|
||||
|
||||
m.victory = false;
|
||||
m.survivors = m.soldiers; // при сборе боя нет -- все живы
|
||||
m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null;
|
||||
m.loot = new List<RewardEntry>();
|
||||
|
||||
if (p == null || !p.active || p.occupiedBy != 0 || m.arriveMs >= p.expireMs)
|
||||
{
|
||||
m.targetEmpty = true;
|
||||
m.gatherEndMs = m.arriveMs;
|
||||
m.returnMs = m.arriveMs + TravelTimeMs(target, from, units);
|
||||
m.state = MarchState.Returning;
|
||||
return;
|
||||
}
|
||||
|
||||
pointRules.Gather(p.kind, units, p.stock, m.arriveMs, p.expireMs, out int amount, out long endMs);
|
||||
m.cargo = amount;
|
||||
m.gatherEndMs = endMs;
|
||||
m.state = MarchState.Gathering;
|
||||
p.occupiedBy = m.id;
|
||||
}
|
||||
|
||||
// Сбор закончен: груз -- в добычу похода (зачислит Экономика по возвращении), войско идёт домой
|
||||
void FinishGather(MarchData m)
|
||||
{
|
||||
MapNode from = GetNode(m.fromNodeId);
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
ResourcePoint p = GetPoint(m.targetNodeId);
|
||||
int[] units = HasUnits(m) ? m.units : null;
|
||||
|
||||
if (p != null)
|
||||
{
|
||||
p.stock = Math.Max(0, p.stock - m.cargo);
|
||||
if (p.occupiedBy == m.id) p.occupiedBy = 0;
|
||||
if (m.cargo > 0) m.loot.Add(new RewardEntry { resource = ResourcePointRules.Resources[p.kind], amount = m.cargo });
|
||||
if (p.stock <= 0 || m.gatherEndMs >= p.expireMs) RetirePoint(p, m.gatherEndMs);
|
||||
}
|
||||
m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units);
|
||||
m.state = MarchState.Returning;
|
||||
}
|
||||
|
||||
// ---------------- Сохранение ----------------
|
||||
|
||||
public ResourcePointsState GetPointsState()
|
||||
{
|
||||
var state = new ResourcePointsState
|
||||
{
|
||||
nextPointId = nextPointId,
|
||||
generation = pointGeneration,
|
||||
initialized = pointsInitialized
|
||||
};
|
||||
foreach (var p in points) state.points.Add(p);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Загружать ДО походов: поход к точке восстанавливается, только если точка есть на карте
|
||||
public void LoadPointsState(ResourcePointsState state)
|
||||
{
|
||||
nodes.RemoveAll(n => n.type == MapNodeType.ResourcePoint);
|
||||
points.Clear();
|
||||
nextPointId = state?.nextPointId ?? 1;
|
||||
pointGeneration = state?.generation ?? 0;
|
||||
pointsInitialized = state?.initialized ?? false;
|
||||
pointsTopUp = true; // таблица могла измениться -- при следующем расчёте мира добавить недостающие
|
||||
if (state?.points == null) return;
|
||||
foreach (var p in state.points)
|
||||
{
|
||||
if (p == null || p.kind < 0 || p.kind >= ResourcePointRules.Resources.Length || p.size < 0 || p.size > 2) continue;
|
||||
points.Add(p);
|
||||
nodes.Add(ToNode(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: caa1c535b9b8440c95d48f1928e6b78f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace Bogatyri.World
|
||||
{
|
||||
@@ -7,7 +8,8 @@ namespace Bogatyri.World
|
||||
{
|
||||
Outbound, // идут к цели
|
||||
Returning, // бой прошёл, возвращаются домой
|
||||
Finished // вернулись, солдаты и добыча зачислены
|
||||
Finished, // вернулись, солдаты и добыча зачислены
|
||||
Gathering // стоят на ресурсной точке и собирают (регламент 31.10)
|
||||
}
|
||||
|
||||
// Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд":
|
||||
@@ -19,15 +21,22 @@ namespace Bogatyri.World
|
||||
public string ownerId;
|
||||
public string fromNodeId;
|
||||
public string targetNodeId;
|
||||
public string targetName; // название цели на момент выступления: истощённая точка может исчезнуть с карты раньше, чем показан итог
|
||||
|
||||
public int soldiers; // сколько ушло
|
||||
public int survivors; // сколько возвращается (известно после боя)
|
||||
public bool victory;
|
||||
public bool targetEmpty; // лагерь уже был разбит, когда войско пришло: боя нет, все возвращаются
|
||||
public bool targetEmpty; // лагерь уже разбит / ресурсная точка исчезла или занята: боя и сбора нет, все возвращаются
|
||||
public bool gather; // поход на ресурсную точку: сбор вместо боя
|
||||
public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается
|
||||
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
|
||||
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
|
||||
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
|
||||
public int defenders; // гарнизон цели в момент боя
|
||||
public int defenderLosses; // сколько потерял защитник
|
||||
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
|
||||
public int[] survivorUnits; // выжившие по типам (известно после боя)
|
||||
public int[] woundedUnits; // раненые по типам: идут домой с войском и ложатся в госпиталь (решения 31.10, п. 7)
|
||||
|
||||
public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется
|
||||
public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента)
|
||||
@@ -44,21 +53,82 @@ namespace Bogatyri.World
|
||||
public MarchState state;
|
||||
}
|
||||
|
||||
// Скорости маршей (регламент 31.10, раздел 7, ТЕСТ). Физический марш идёт в РЕАЛЬНОМ времени (регламент, раздел 2):
|
||||
// скорость -- единиц карты за 1 реальный час, единица карты = 1 клетка = 1000 милли-единиц координат.
|
||||
// Время мира -- миллисекунды часов GameClock (реальное время + перемотка DEV).
|
||||
//
|
||||
// Смешанное войско (решения по 8 вопросам Саши, 31.10, п. 4) -- средняя скорость по численности с модификаторами:
|
||||
// V = Σ(Nᵢ × Vᵢ × Mᵢ) / ΣNᵢ, общий модификатор войска -- один раз, без промежуточного округления.
|
||||
// Пример: 10 пехоты + 10 кавалерии = (10×8 + 10×12) / 20 = 10 клеток в час; общий бафф +10% -- 11.
|
||||
public sealed class MarchSpeedRules
|
||||
{
|
||||
// Милли-единиц карты за реальный час по типам войск (TroopType): пехота 8, лучники 9, кавалерия 12, осадные 4
|
||||
public int[] SpeedMilliPerHour = { 8000, 9000, 12000, 4000 };
|
||||
|
||||
// Модификатор скорости каждого типа, в процентах: +10 -- быстрее на 10%, -20 -- медленнее.
|
||||
// Позже сюда придут бонусы героев и технологий по типам; сейчас -- 0 (ТЕСТ, можно менять в MarchConfig)
|
||||
public int[] TypeBonusPercent = { 0, 0, 0, 0 };
|
||||
|
||||
// Общий модификатор войска, в процентах -- применяется один раз ко всему войску
|
||||
public int ArmyBonusPercent = 0;
|
||||
|
||||
public static MarchSpeedRules Default => new MarchSpeedRules();
|
||||
|
||||
// Скорость войска как точная дробь num / den (милли-единиц в час). Состав неизвестен (старый поход) -- как у пехоты.
|
||||
void Speed(int[] units, out BigInteger num, out BigInteger den)
|
||||
{
|
||||
num = BigInteger.Zero;
|
||||
long count = 0;
|
||||
for (int i = 0; units != null && i < units.Length && i < SpeedMilliPerHour.Length; i++)
|
||||
{
|
||||
if (units[i] <= 0) continue;
|
||||
num += (BigInteger)units[i] * Math.Max(1, SpeedMilliPerHour[i]) * TypeFactor(i);
|
||||
count += units[i];
|
||||
}
|
||||
if (count == 0)
|
||||
{
|
||||
num = (BigInteger)Math.Max(1, SpeedMilliPerHour[TroopType.Infantry]) * TypeFactor(TroopType.Infantry);
|
||||
count = 1;
|
||||
}
|
||||
num *= Math.Max(1, 100 + ArmyBonusPercent);
|
||||
den = (BigInteger)count * 100 * 100;
|
||||
if (num.IsZero) num = BigInteger.One;
|
||||
}
|
||||
|
||||
int TypeFactor(int type)
|
||||
{
|
||||
int bonus = TypeBonusPercent != null && type < TypeBonusPercent.Length ? TypeBonusPercent[type] : 0;
|
||||
return Math.Max(1, 100 + bonus); // скорость не бывает нулевой или отрицательной
|
||||
}
|
||||
|
||||
// Скорость войска данного состава, милли-единиц за реальный час (округлено вниз -- только для показа)
|
||||
public long SpeedFor(int[] units)
|
||||
{
|
||||
Speed(units, out BigInteger num, out BigInteger den);
|
||||
return Math.Max(1, (long)(num / den));
|
||||
}
|
||||
|
||||
// Время в пути в реальных миллисекундах: расстояние / скорость, одно округление вниз в самом конце
|
||||
public long TravelTimeMs(MapNode from, MapNode to, int[] units)
|
||||
{
|
||||
Speed(units, out BigInteger num, out BigInteger den);
|
||||
return (long)((BigInteger)WorldMapLayout.Distance(from, to) * 3_600_000 * den / num);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MarchRules
|
||||
{
|
||||
// Скорость войска: 400 милли-единиц (0,4 клетки) в секунду.
|
||||
// Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты.
|
||||
public const int SpeedPerSecond = 400;
|
||||
|
||||
// Время в пути войска без известного состава (как у пехоты) при скоростях по умолчанию.
|
||||
// Для настоящего похода -- WorldSimulation.TravelTimeMs: скорости из таблицы MarchConfig и состав войска.
|
||||
public static long GetTravelTimeMs(MapNode from, MapNode to)
|
||||
{
|
||||
return (long)WorldMapLayout.Distance(from, to) * 1000 / SpeedPerSecond;
|
||||
return MarchSpeedRules.Default.TravelTimeMs(from, to, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static class CampRules
|
||||
{
|
||||
// Разбитый лагерь появляется снова через 30 минут (требует балансировки)
|
||||
// Разбитый лагерь появляется снова через 30 РЕАЛЬНЫХ минут после боя (план от 27.10.2026: "30 реальных минут"). ТЕСТ.
|
||||
public const long RespawnMs = 30L * 60 * 1000;
|
||||
}
|
||||
|
||||
@@ -73,34 +143,61 @@ namespace Bogatyri.World
|
||||
public enum WorldEventType
|
||||
{
|
||||
BattleResolved, // войско дошло, бой прошёл
|
||||
MarchReturned // войско вернулось домой
|
||||
MarchReturned, // войско вернулось домой
|
||||
GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty)
|
||||
GatherFinished, // сбор закончен, войско идёт домой с грузом
|
||||
ScoutArrived // разведка дошла до цели и повернула домой (targetEmpty -- цели уже нет)
|
||||
}
|
||||
|
||||
public struct WorldEvent
|
||||
{
|
||||
public WorldEventType Type;
|
||||
public MarchData March;
|
||||
public long TimeMs; // когда событие случилось по часам игры (а не когда его заметили)
|
||||
}
|
||||
|
||||
// Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере,
|
||||
// а клиент только показывает то, что он вернул.
|
||||
public sealed class WorldSimulation
|
||||
public sealed partial class WorldSimulation
|
||||
{
|
||||
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;
|
||||
public IReadOnlyList<MarchData> Marches => marches;
|
||||
|
||||
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам
|
||||
public WorldSimulation(List<MapNode> nodes, IBattleSystem battleSystem)
|
||||
private readonly MarchSpeedRules speeds;
|
||||
public MarchSpeedRules Speeds => speeds;
|
||||
|
||||
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам.
|
||||
// Скорости маршей -- из таблицы (в игре MarchConfig), без неё -- значения регламента по умолчанию.
|
||||
public WorldSimulation(List<MapNode> nodes, IBattleSystem battleSystem, MarchSpeedRules speeds = null,
|
||||
ResourcePointRules pointRules = null, int seed = WorldMapLayout.DefaultSeed)
|
||||
{
|
||||
this.nodes = nodes;
|
||||
this.battleSystem = battleSystem;
|
||||
this.speeds = speeds ?? MarchSpeedRules.Default;
|
||||
this.pointRules = pointRules ?? ResourcePointRules.Default;
|
||||
this.pointSeed = seed;
|
||||
}
|
||||
|
||||
// Время в пути войска данного состава (реальные миллисекунды)
|
||||
public long TravelTimeMs(MapNode from, MapNode to, int[] units)
|
||||
{
|
||||
return speeds.TravelTimeMs(from, to, units);
|
||||
}
|
||||
|
||||
// Состав похода стал известен (Армия забрала воинов): время прихода пересчитывается по скорости состава.
|
||||
// Только пока войско в пути и ещё не дошло -- после боя время уже не меняется.
|
||||
public bool AssignUnits(MarchData m, int[] units)
|
||||
{
|
||||
if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
|
||||
m.units = units;
|
||||
m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Состояние войска нападающего на момент боя (голод в его поселении, усталость).
|
||||
@@ -159,19 +256,26 @@ namespace Bogatyri.World
|
||||
|
||||
// ---------------- Походы ----------------
|
||||
|
||||
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error)
|
||||
// scout -- поход «Разведка»: к любой цели, кроме своего города; без боя, без сбора, разбитый лагерь и занятая точка не мешают
|
||||
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error, bool scout = false)
|
||||
{
|
||||
MapNode from = GetNode(fromId);
|
||||
MapNode target = GetNode(targetId);
|
||||
|
||||
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))
|
||||
if (!scout && target.type == MapNodeType.Camp && IsCampCleared(targetId, nowMs))
|
||||
{
|
||||
error = $"{target.displayName} уже разбит. Появится снова через {(CampRespawnInMs(targetId, nowMs) + 59_999) / 60_000} мин";
|
||||
return null;
|
||||
}
|
||||
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
|
||||
if (target.type == MapNodeType.ResourcePoint && !scout && !CanGatherAt(targetId, nowMs, out error)) return null;
|
||||
if (target.type == MapNodeType.ResourcePoint && scout)
|
||||
{
|
||||
ResourcePoint rp = GetPoint(targetId);
|
||||
if (rp == null || !rp.active || nowMs >= rp.expireMs) { error = "Этой ресурсной точки больше нет"; return null; }
|
||||
}
|
||||
|
||||
var march = new MarchData
|
||||
{
|
||||
@@ -182,8 +286,11 @@ namespace Bogatyri.World
|
||||
soldiers = soldiers,
|
||||
survivors = soldiers,
|
||||
departMs = nowMs,
|
||||
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
|
||||
state = MarchState.Outbound
|
||||
arriveMs = nowMs + TravelTimeMs(from, target, null), // состав уточнит AssignUnits
|
||||
state = MarchState.Outbound,
|
||||
gather = !scout && target.type == MapNodeType.ResourcePoint,
|
||||
scout = scout,
|
||||
targetName = target.displayName
|
||||
};
|
||||
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
|
||||
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
|
||||
@@ -208,27 +315,93 @@ namespace Bogatyri.World
|
||||
// может и дойти, и вернуться -- события придут в правильном порядке.
|
||||
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 });
|
||||
}
|
||||
EnsureResourcePoints(nowMs); // первая игра: ресурсные точки появляются на карте
|
||||
SpawnPatrolsUntil(nowMs); // патрули-заглушки соседей (если включены)
|
||||
|
||||
foreach (var m in marches)
|
||||
// События -- строго по времени (документ 02: офлайн в причинно-следственном порядке).
|
||||
// Каждый раз берётся самое раннее: приход к цели (бой или начало сбора), конец сбора,
|
||||
// исчезновение ресурсной точки или возвращение домой. Событие назначает следующее время похода,
|
||||
// поэтому за один вызов поход может дойти, собрать и вернуться. Кто первым дошёл до лагеря или точки --
|
||||
// того и цель. При равном времени: приход, конец сбора, исчезновение точки, возвращение; затем по номеру.
|
||||
while (true)
|
||||
{
|
||||
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
|
||||
MarchData next = null;
|
||||
ResourcePoint expiring = null;
|
||||
long nextMs = 0;
|
||||
int nextKind = int.MaxValue;
|
||||
int nextId = int.MaxValue;
|
||||
foreach (var m in marches)
|
||||
{
|
||||
m.state = MarchState.Finished;
|
||||
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = m });
|
||||
int kind;
|
||||
long at;
|
||||
if (m.state == MarchState.Outbound) { kind = 0; at = m.arriveMs; }
|
||||
else if (m.state == MarchState.Gathering) { kind = 1; at = m.gatherEndMs; }
|
||||
else if (m.state == MarchState.Returning) { kind = 3; at = m.returnMs; }
|
||||
else continue;
|
||||
if (at > nowMs) continue;
|
||||
if (next == null && expiring == null || at < nextMs || at == nextMs && (kind < nextKind || kind == nextKind && m.id < nextId))
|
||||
{
|
||||
next = m; expiring = null; nextMs = at; nextKind = kind; nextId = m.id;
|
||||
}
|
||||
}
|
||||
foreach (var p in points)
|
||||
{
|
||||
// Точка без войска исчезает в срок (с войском -- её срок уже учтён в конце сбора)
|
||||
if (!p.active || p.occupiedBy != 0 || p.expireMs > nowMs) continue;
|
||||
if (next == null && expiring == null || p.expireMs < nextMs || p.expireMs == nextMs && 2 < nextKind)
|
||||
{
|
||||
next = null; expiring = p; nextMs = p.expireMs; nextKind = 2; nextId = int.MaxValue;
|
||||
}
|
||||
}
|
||||
if (next == null && expiring == null) break;
|
||||
|
||||
if (expiring != null)
|
||||
{
|
||||
RetirePoint(expiring, nextMs);
|
||||
}
|
||||
else if (next.patrol)
|
||||
{
|
||||
// Патруль: дошёл -- повернул обратно, вернулся -- исчез. Событий для игрока нет
|
||||
next.state = nextKind == 0 ? MarchState.Returning : MarchState.Finished;
|
||||
}
|
||||
else if (next.scout)
|
||||
{
|
||||
// Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома
|
||||
if (nextKind == 0)
|
||||
{
|
||||
ArriveScout(next);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
|
||||
}
|
||||
else
|
||||
{
|
||||
next.state = MarchState.Finished;
|
||||
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = next, TimeMs = nextMs });
|
||||
}
|
||||
}
|
||||
else if (nextKind == 0 && next.gather)
|
||||
{
|
||||
StartGather(next);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.GatherStarted, March = next, TimeMs = nextMs });
|
||||
}
|
||||
else if (nextKind == 0)
|
||||
{
|
||||
ResolveBattle(next);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = next, TimeMs = nextMs });
|
||||
}
|
||||
else if (nextKind == 1)
|
||||
{
|
||||
FinishGather(next);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.GatherFinished, March = next, TimeMs = nextMs });
|
||||
}
|
||||
else
|
||||
{
|
||||
next.state = MarchState.Finished;
|
||||
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = next, TimeMs = nextMs });
|
||||
}
|
||||
}
|
||||
|
||||
marches.RemoveAll(m => m.state == MarchState.Finished);
|
||||
PurgeRetiredPoints();
|
||||
|
||||
// Лагеря, которые уже появились снова, больше не нужно помнить
|
||||
expiredCamps.Clear();
|
||||
@@ -244,7 +417,7 @@ namespace Bogatyri.World
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
|
||||
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
|
||||
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
|
||||
m.returnMs = m.arriveMs + TravelTimeMs(target, from, HasUnits(m) ? m.units : null);
|
||||
m.state = MarchState.Returning;
|
||||
m.loot = new List<RewardEntry>();
|
||||
|
||||
@@ -280,6 +453,9 @@ namespace Bogatyri.World
|
||||
m.victory = outcome.Victory;
|
||||
m.survivors = outcome.Survivors;
|
||||
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
|
||||
m.woundedUnits = HasUnits(m) ? outcome.AttackerWoundedUnits : null; // старый поход без состава -- раненые как погибшие
|
||||
if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава
|
||||
m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null));
|
||||
m.defenders = target.garrison;
|
||||
m.defenderLosses = outcome.DefenderLosses;
|
||||
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
|
||||
@@ -294,6 +470,30 @@ namespace Bogatyri.World
|
||||
}
|
||||
}
|
||||
|
||||
// Разведка у цели: боя, сбора и потерь нет -- все идут домой тем же составом. Цели уже нет (точка исчезла) --
|
||||
// дошли до места и вернулись физически, без телепортации (решения 31.10, п. 2)
|
||||
void ArriveScout(MarchData m)
|
||||
{
|
||||
MapNode from = GetNode(m.fromNodeId);
|
||||
MapNode target = GetNode(m.targetNodeId);
|
||||
int[] units = HasUnits(m) ? m.units : null;
|
||||
m.returnMs = m.arriveMs + TravelTimeMs(target, from, units);
|
||||
m.state = MarchState.Returning;
|
||||
m.victory = false;
|
||||
m.survivors = m.soldiers;
|
||||
m.survivorUnits = units != null ? (int[])units.Clone() : null;
|
||||
m.loot = new List<RewardEntry>();
|
||||
ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null;
|
||||
m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active);
|
||||
}
|
||||
|
||||
// Название цели похода: запомненное при выступлении (старые походы -- с карты)
|
||||
public string TargetName(MarchData m)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(m.targetName)) return m.targetName;
|
||||
return GetNode(m.targetNodeId)?.displayName ?? "";
|
||||
}
|
||||
|
||||
// Номер боя из данных похода (FNV-1a) -- одинаков на любой платформе
|
||||
public static long BattleSeed(int marchId, long departMs, string targetId)
|
||||
{
|
||||
@@ -328,11 +528,19 @@ namespace Bogatyri.World
|
||||
start = m.departMs;
|
||||
end = m.arriveMs;
|
||||
}
|
||||
else if (m.state == MarchState.Gathering)
|
||||
{
|
||||
// Войско стоит на точке и собирает
|
||||
segmentFrom = target;
|
||||
segmentTo = home;
|
||||
progress = 0;
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
segmentFrom = target;
|
||||
segmentTo = home;
|
||||
start = m.arriveMs;
|
||||
start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора
|
||||
end = m.returnMs;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,19 @@ public static class MarchReturnPopup
|
||||
showing = true;
|
||||
|
||||
MarchReturnedEvent e = queue.Dequeue();
|
||||
string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»";
|
||||
string result = e.TargetEmpty ? "лагерь уже был разбит, боя не было"
|
||||
string target = string.IsNullOrEmpty(e.TargetName) ? "поход"
|
||||
: (e.IsScout ? "Разведка" : e.IsGather ? "Сбор" : "Поход на") + $" «{e.TargetName}»";
|
||||
string result = e.IsScout
|
||||
? (e.TargetEmpty ? "цели уже не было" : "<color=#7CD67C>цель разведана</color>")
|
||||
: e.IsGather
|
||||
? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "<color=#7CD67C>ресурсы собраны</color>")
|
||||
: e.TargetEmpty ? "лагерь уже был разбит, боя не было"
|
||||
: e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
|
||||
int lost = Mathf.Max(0, e.Sent - e.Survivors);
|
||||
int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded);
|
||||
|
||||
string text = $"{target}: {result}\n" +
|
||||
$"Вернулись {e.Survivors} из {e.Sent} воинов" + (lost > 0 ? $" (погибли {lost})" : "") + "\n" +
|
||||
$"Вернулись {e.Survivors} из {e.Sent} воинов" + (dead > 0 ? $" (погибли {dead})" : "") + "\n" +
|
||||
(e.Wounded > 0 ? $"Раненых {e.Wounded} -- в госпитале (окно «Войска»)\n" : "") +
|
||||
$"Добыча: {e.LootText}";
|
||||
|
||||
UIKit.Message("ВОЙСКО ВЕРНУЛОСЬ", text, "ОК", ShowNext);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Collections.Generic;
|
||||
using Bogatyri.World;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// Окно «Армии» (слой приложения): где каждое войско игрока, что делает, состав, груз и сколько ждать.
|
||||
// Регламент 31.10, раздел 7: «Панель армий нужна всем игрокам: местонахождение, действие, состав, груз,
|
||||
// время марша, добычи и ожидаемого возвращения». Данные -- модули Мира (походы) и Армии (дома, гарнизон, слоты).
|
||||
// Документ: Docs/Modules/World.md
|
||||
//
|
||||
// Открыть: ArmiesWindow.Open() -- кнопка «Армии» на карте мира. Таймеры обновляются сами, пока окно открыто.
|
||||
public static class ArmiesWindow
|
||||
{
|
||||
const float ListHeight = 620f;
|
||||
|
||||
static GameObject root;
|
||||
|
||||
public static bool IsOpen => root != null;
|
||||
|
||||
public static void Open()
|
||||
{
|
||||
Close();
|
||||
if (MarchManager.Instance == null) return;
|
||||
RectTransform card = UIKit.Modal("Армии", out root);
|
||||
var view = root.AddComponent<ArmiesView>();
|
||||
view.Build(card);
|
||||
|
||||
var close = UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 90, 30);
|
||||
UIKit.Size(close, -1, 90);
|
||||
}
|
||||
|
||||
public static void Close()
|
||||
{
|
||||
if (root != null) Object.Destroy(root);
|
||||
root = null;
|
||||
}
|
||||
|
||||
// Живая часть окна: строки походов обновляются 2 раза в секунду, список -- когда походы меняются
|
||||
class ArmiesView : MonoBehaviour
|
||||
{
|
||||
TextMeshProUGUI summary;
|
||||
RectTransform list;
|
||||
readonly List<(int marchId, TextMeshProUGUI text)> rows = new List<(int, TextMeshProUGUI)>();
|
||||
string shownKey;
|
||||
float nextRefresh;
|
||||
|
||||
WorldSimulation World => MarchManager.Instance.World;
|
||||
|
||||
public void Build(RectTransform card)
|
||||
{
|
||||
summary = UIKit.Text(card, "", 30, UIKit.Theme.Text, TextAlignmentOptions.Center);
|
||||
var holder = UIKit.Make("ListHolder", card);
|
||||
UIKit.Size(holder, -1, ListHeight);
|
||||
list = UIKit.ScrollList(holder, out ScrollRect _, 10);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (Time.unscaledTime < nextRefresh) return;
|
||||
nextRefresh = Time.unscaledTime + 0.5f;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
void Refresh()
|
||||
{
|
||||
long now = MarchManager.NowMs;
|
||||
var mine = new List<MarchData>();
|
||||
foreach (var m in World.Marches)
|
||||
if (m.ownerId == MarchManager.LocalPlayerId) mine.Add(m);
|
||||
|
||||
// Дома: свободные, гарнизон, слоты похода
|
||||
if (GameServices.TryGet(out IArmy army))
|
||||
summary.text = $"Дома свободно: {army.AvailableForMarch} • гарнизон: {army.GarrisonTotal} • " +
|
||||
$"походов: {army.ActiveMarches} из {army.MarchSlots}";
|
||||
|
||||
// Состав списка поменялся -- строим заново, иначе только обновляем тексты
|
||||
string key = string.Join(",", mine.ConvertAll(m => m.id + ":" + (int)m.state));
|
||||
if (key != shownKey)
|
||||
{
|
||||
shownKey = key;
|
||||
UIKit.Clear(list);
|
||||
rows.Clear();
|
||||
if (mine.Count == 0)
|
||||
UIKit.Text(list, "Все войска дома. Отправьте войско: нажмите на цель на карте.", 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
foreach (var m in mine)
|
||||
{
|
||||
var row = UIKit.Row(list, 200, 16, UIKit.Theme.Card, 16); // крупно -- читается на телефоне
|
||||
UIKit.Icon(row, StateColor(m), StateLetter(m), 96);
|
||||
var text = UIKit.Text(row, "", 28, UIKit.Theme.Text);
|
||||
UIKit.Size(text, -1, 188, 1);
|
||||
rows.Add((m.id, text));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, text) in rows)
|
||||
{
|
||||
MarchData m = mine.Find(x => x.id == id);
|
||||
if (m != null) text.text = Describe(m, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Четыре строки: куда и что делает, состав, груз, сколько ждать
|
||||
string Describe(MarchData m, long now)
|
||||
{
|
||||
string target = World.TargetName(m);
|
||||
int soldiers = m.state == MarchState.Returning ? m.survivors : m.soldiers;
|
||||
int[] units = m.state == MarchState.Returning && m.survivorUnits != null ? m.survivorUnits : m.units;
|
||||
|
||||
string action;
|
||||
string timer;
|
||||
switch (m.state)
|
||||
{
|
||||
case MarchState.Outbound:
|
||||
action = m.scout ? $"идёт в разведку: <b>{target}</b>"
|
||||
: m.gather ? $"идёт на сбор: <b>{target}</b>" : $"идёт в бой: <b>{target}</b>";
|
||||
timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}";
|
||||
break;
|
||||
case MarchState.Gathering:
|
||||
action = $"собирает: <b>{target}</b>";
|
||||
timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}";
|
||||
break;
|
||||
default:
|
||||
action = (m.scout ? "возвращается из разведки: " : m.gather ? "возвращается со сбора: "
|
||||
: m.victory ? "возвращается с победой: " : "возвращается: ") + $"<b>{target}</b>";
|
||||
timer = $"дома через {Clock(m.returnMs - now)}";
|
||||
break;
|
||||
}
|
||||
|
||||
string cargo = m.state == MarchState.Outbound
|
||||
? (m.gather ? "груз: будет известен на точке" : "груз: нет")
|
||||
: "груз: " + MarchManager.FormatLoot(m.state == MarchState.Gathering ? CargoAsLoot(m) : m.loot);
|
||||
|
||||
return $"{action}\n" +
|
||||
$"<color=#C8C8C8>{soldiers} воинов: {UnitsText(units)}</color>\n" +
|
||||
$"<color=#C8C8C8>{cargo}</color>\n" +
|
||||
$"<color=#E8C15A>{timer}</color>";
|
||||
}
|
||||
|
||||
// Когда войско будет дома: после боя -- обратный путь, после сбора -- сбор и обратный путь
|
||||
long ExpectedReturn(MarchData m)
|
||||
{
|
||||
if (m.state == MarchState.Returning) return m.returnMs;
|
||||
MapNode home = World.GetNode(m.fromNodeId);
|
||||
MapNode target = World.GetNode(m.targetNodeId);
|
||||
if (home == null || target == null) return m.returnMs;
|
||||
if (m.state == MarchState.Gathering) return m.gatherEndMs + World.TravelTimeMs(target, home, m.units);
|
||||
long gather = 0;
|
||||
if (m.gather)
|
||||
{
|
||||
World.PreviewGather(m.targetNodeId, m.units, m.arriveMs, out _, out gather);
|
||||
}
|
||||
return m.arriveMs + gather + World.TravelTimeMs(target, home, m.units); // после боя -- если все живы
|
||||
}
|
||||
|
||||
List<RewardEntry> CargoAsLoot(MarchData m)
|
||||
{
|
||||
ResourcePoint p = World.GetPoint(m.targetNodeId);
|
||||
var loot = new List<RewardEntry>();
|
||||
if (p != null && m.cargo > 0) loot.Add(new RewardEntry { resource = ResourcePointRules.Resources[p.kind], amount = m.cargo });
|
||||
return loot;
|
||||
}
|
||||
|
||||
static string UnitsText(int[] u)
|
||||
{
|
||||
if (u == null || u.Length < TroopType.Count) return "состав неизвестен";
|
||||
var parts = new List<string>();
|
||||
if (u[TroopType.Infantry] > 0) parts.Add($"пех. {u[TroopType.Infantry]}");
|
||||
if (u[TroopType.Archers] > 0) parts.Add($"луч. {u[TroopType.Archers]}");
|
||||
if (u[TroopType.Cavalry] > 0) parts.Add($"кав. {u[TroopType.Cavalry]}");
|
||||
if (u[TroopType.Siege] > 0) parts.Add($"осад. {u[TroopType.Siege]}");
|
||||
return parts.Count > 0 ? string.Join(", ", parts) : "никого";
|
||||
}
|
||||
|
||||
// Реальное время: «1 ч 05 мин», меньше часа -- «12:30»
|
||||
static string Clock(long ms)
|
||||
{
|
||||
long sec = System.Math.Max(0, (ms + 999) / 1000);
|
||||
if (sec >= 3600) return $"{sec / 3600} ч {sec / 60 % 60:00} мин";
|
||||
return $"{sec / 60}:{sec % 60:00}";
|
||||
}
|
||||
|
||||
static Color StateColor(MarchData m)
|
||||
{
|
||||
switch (m.state)
|
||||
{
|
||||
case MarchState.Outbound: return m.scout ? new Color(0.45f, 0.45f, 0.55f) : m.gather ? new Color(0.25f, 0.55f, 0.3f) : new Color(0.7f, 0.25f, 0.2f);
|
||||
case MarchState.Gathering: return new Color(0.75f, 0.6f, 0.15f);
|
||||
default: return new Color(0.25f, 0.45f, 0.75f);
|
||||
}
|
||||
}
|
||||
|
||||
static string StateLetter(MarchData m)
|
||||
{
|
||||
switch (m.state)
|
||||
{
|
||||
case MarchState.Outbound: return m.scout ? "Р" : m.gather ? "С" : "Б"; // разведка / на сбор / в бой
|
||||
case MarchState.Gathering: return "Д"; // добыча
|
||||
default: return "В"; // возвращается
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aae71fe15d5b42cabbc00038dab0e9fe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Collections.Generic;
|
||||
using Bogatyri.World;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
// Разведка на карте мира (М1): туман войны и чужие армии. Правил тут нет -- всё знает модуль Разведки (IIntelligence),
|
||||
// здесь только рисуем то, что он вернул.
|
||||
//
|
||||
// Туман: неисследованное -- тёмное, исследованное -- притушено, в зоне обзора -- чисто.
|
||||
// По умолчанию туман только затемняет землю: города, лагеря и точки видны (IntelligenceConfig.hideUnexploredObjects).
|
||||
// Чужие армии -- только те, что знает Разведка: в обзоре -- красные, с примерной численностью «~min–max»
|
||||
// и владельцем, если опознан; вышли из обзора -- бледная метка последнего контакта.
|
||||
public class IntelligenceOverlay : MonoBehaviour
|
||||
{
|
||||
static readonly Color ObservedColor = new Color(0.9f, 0.25f, 0.2f);
|
||||
static readonly Color GhostColor = new Color(0.9f, 0.25f, 0.2f, 0.35f);
|
||||
const float MarkerSize = 0.55f;
|
||||
const int CellSize = 1000;
|
||||
|
||||
private Transform mapRoot;
|
||||
private WorldSimulation world;
|
||||
private Texture2D fog;
|
||||
private Color32[] pixels;
|
||||
private int gridW, gridH;
|
||||
private float nextFogRefresh;
|
||||
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> markers = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
|
||||
private readonly HashSet<string> alive = new HashSet<string>();
|
||||
private readonly List<string> stale = new List<string>();
|
||||
|
||||
public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world)
|
||||
{
|
||||
var go = new GameObject("Intelligence");
|
||||
go.transform.SetParent(mapRoot, false);
|
||||
var overlay = go.AddComponent<IntelligenceOverlay>();
|
||||
overlay.mapRoot = mapRoot;
|
||||
overlay.world = world;
|
||||
overlay.CreateFog();
|
||||
return overlay;
|
||||
}
|
||||
|
||||
void CreateFog()
|
||||
{
|
||||
gridW = (2 * WorldMapLayout.HalfWidth + CellSize - 1) / CellSize;
|
||||
gridH = (2 * WorldMapLayout.HalfHeight + CellSize - 1) / CellSize;
|
||||
fog = new Texture2D(gridW, gridH, TextureFormat.RGBA32, false)
|
||||
{
|
||||
filterMode = FilterMode.Bilinear, // мягкий край тумана
|
||||
wrapMode = TextureWrapMode.Clamp
|
||||
};
|
||||
pixels = new Color32[gridW * gridH];
|
||||
|
||||
var go = new GameObject("Fog");
|
||||
go.transform.SetParent(transform, false);
|
||||
var sr = go.AddComponent<SpriteRenderer>();
|
||||
// 1 пиксель = 1 клетка карты = 1 единица Unity; центр спрайта -- центр карты
|
||||
sr.sprite = Sprite.Create(fog, new Rect(0, 0, gridW, gridH), new Vector2(0.5f, 0.5f), 1f);
|
||||
go.transform.position = new Vector3((gridW * CellSize / 2f - WorldMapLayout.HalfWidth) / 1000f,
|
||||
(gridH * CellSize / 2f - WorldMapLayout.HalfHeight) / 1000f, 0f);
|
||||
// Объекты видны -- туман между землёй (-10) и объектами (0+). Скрыты -- поверх объектов, под войсками (30)
|
||||
sr.sortingOrder = IntelligenceConfig.Load().hideUnexploredObjects ? 20 : -9;
|
||||
RefreshFog();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (fog != null) Destroy(fog);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!GameServices.TryGet(out IIntelligence intel)) return;
|
||||
if (Time.unscaledTime >= nextFogRefresh)
|
||||
{
|
||||
nextFogRefresh = Time.unscaledTime + 0.5f;
|
||||
RefreshFog();
|
||||
}
|
||||
RefreshContacts(intel);
|
||||
}
|
||||
|
||||
void RefreshFog()
|
||||
{
|
||||
if (!GameServices.TryGet(out IIntelligence intel)) return;
|
||||
bool hide = IntelligenceConfig.Load().hideUnexploredObjects;
|
||||
byte unexplored = (byte)(hide ? 250 : 220), explored = 110;
|
||||
for (int cy = 0; cy < gridH; cy++)
|
||||
for (int cx = 0; cx < gridW; cx++)
|
||||
{
|
||||
int x = cx * CellSize + CellSize / 2 - WorldMapLayout.HalfWidth;
|
||||
int y = cy * CellSize + CellSize / 2 - WorldMapLayout.HalfHeight;
|
||||
MapVisibility v = intel.GetVisibility(x, y);
|
||||
byte a = v == MapVisibility.Observed ? (byte)0 : v == MapVisibility.Explored ? explored : unexplored;
|
||||
pixels[cy * gridW + cx] = new Color32(10, 14, 20, a);
|
||||
}
|
||||
fog.SetPixels32(pixels);
|
||||
fog.Apply(false);
|
||||
}
|
||||
|
||||
void RefreshContacts(IIntelligence intel)
|
||||
{
|
||||
long now = MarchManager.NowMs;
|
||||
alive.Clear();
|
||||
foreach (var c in intel.Contacts)
|
||||
{
|
||||
alive.Add(c.ArmyId);
|
||||
if (!markers.TryGetValue(c.ArmyId, out var m))
|
||||
{
|
||||
m = CreateMarker(c.ArmyId);
|
||||
markers[c.ArmyId] = m;
|
||||
}
|
||||
m.sprite.transform.position = new Vector3(c.X / 1000f, c.Y / 1000f, 0f);
|
||||
m.label.transform.position = m.sprite.transform.position + new Vector3(0, 0.75f, 0);
|
||||
m.sprite.color = c.Observed ? ObservedColor : GhostColor;
|
||||
|
||||
string owner = c.Identified ? OwnerName(c.OwnerId) : "чужая армия";
|
||||
m.label.text = c.Observed
|
||||
? $"{owner}\n~{c.ApproxMin}–{c.ApproxMax} воинов"
|
||||
: $"<color=#BBBBBB>{owner}, ~{c.ApproxMin}–{c.ApproxMax}\nконтакт {Ago(now - c.LastSeenMs)} назад</color>";
|
||||
}
|
||||
|
||||
// Метки армий, которых Разведка больше не знает (контакт устарел)
|
||||
stale.Clear();
|
||||
foreach (var id in markers.Keys) if (!alive.Contains(id)) stale.Add(id);
|
||||
foreach (var id in stale)
|
||||
{
|
||||
Destroy(markers[id].sprite.gameObject);
|
||||
Destroy(markers[id].label.gameObject);
|
||||
markers.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
(SpriteRenderer, TextMeshPro) CreateMarker(string armyId)
|
||||
{
|
||||
var go = new GameObject("Contact_" + armyId);
|
||||
go.transform.SetParent(mapRoot, false);
|
||||
go.transform.localScale = Vector3.one * MarkerSize;
|
||||
go.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб-флажок: не путается со своими квадратиками
|
||||
var sr = go.AddComponent<SpriteRenderer>();
|
||||
sr.sprite = PrimitiveSprites.Square;
|
||||
sr.sortingOrder = 32;
|
||||
|
||||
var lgo = new GameObject("ContactLabel_" + armyId);
|
||||
lgo.transform.SetParent(mapRoot, false);
|
||||
var label = lgo.AddComponent<TextMeshPro>();
|
||||
label.fontSize = 2.6f;
|
||||
label.alignment = TextAlignmentOptions.Center;
|
||||
label.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
|
||||
label.sortingOrder = 33;
|
||||
return (sr, label);
|
||||
}
|
||||
|
||||
// Имя владельца -- по его поселению на карте («npc_3» -> «Велимир»)
|
||||
string OwnerName(string ownerId)
|
||||
{
|
||||
foreach (var n in world.Nodes)
|
||||
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
|
||||
return ownerId ?? "?";
|
||||
}
|
||||
|
||||
static string Ago(long ms)
|
||||
{
|
||||
long sec = System.Math.Max(0, ms) / 1000;
|
||||
return sec >= 60 ? $"{sec / 60} мин" : $"{sec} сек";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e626c469ca7a45b4ba5ad6b1a52cd5f9
|
||||
@@ -119,9 +119,16 @@ public class MarchView : MonoBehaviour
|
||||
RebuildSquares(soldiers, returning ? ReturningColor : OutboundColor);
|
||||
}
|
||||
|
||||
long endMs = returning ? march.returnMs : march.arriveMs;
|
||||
int secondsLeft = Mathf.Max(0, Mathf.CeilToInt((endMs - nowMs) / 1000f));
|
||||
string direction = returning ? "домой" : "в бой";
|
||||
label.text = $"{soldiers} сол. {direction} {secondsLeft / 60}:{secondsLeft % 60:00}";
|
||||
bool gathering = march.state == MarchState.Gathering;
|
||||
long endMs = returning ? march.returnMs : gathering ? march.gatherEndMs : march.arriveMs;
|
||||
long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000);
|
||||
string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой")
|
||||
: gathering ? $"сбор {march.cargo}"
|
||||
: march.scout ? "разведка" : march.gather ? "на сбор" : "в бой";
|
||||
// Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30»
|
||||
string time = secondsLeft >= 3600
|
||||
? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}"
|
||||
: $"{secondsLeft / 60}:{secondsLeft % 60:00}";
|
||||
label.text = $"{soldiers} сол. {direction} {time}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,30 @@ public class WorldMapController : MonoBehaviour
|
||||
private readonly List<int> staleMarchIds = new List<int>();
|
||||
private MapNode selectedTarget;
|
||||
|
||||
// Подписи замка и соседей: гарнизон виден только в зоне обзора (решения 31.10, п. 1)
|
||||
private readonly List<(MapNode node, TextMeshPro label)> garrisonLabels = new List<(MapNode, TextMeshPro)>();
|
||||
|
||||
// Лагеря меняют вид: разбит -- серый, с таймером до появления
|
||||
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
|
||||
|
||||
// Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры
|
||||
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
|
||||
private readonly List<string> stalePoints = new List<string>();
|
||||
const float PointSize = 0.7f;
|
||||
static readonly Color[] PointColors =
|
||||
{
|
||||
new Color(0.2f, 0.55f, 0.2f), // дерево
|
||||
new Color(0.6f, 0.6f, 0.62f), // камень
|
||||
new Color(0.35f, 0.45f, 0.6f), // металл
|
||||
new Color(0.95f, 0.78f, 0.2f) // золото
|
||||
};
|
||||
static readonly string[] ResourceGenitive = { "дерева", "камня", "металла", "золота" };
|
||||
private float nextCampRefresh;
|
||||
|
||||
// Окно атаки: постоянная часть текста и состояние войска, с которым оно показано
|
||||
private string attackInfoBase;
|
||||
private BattleCondition shownConditions;
|
||||
private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось
|
||||
|
||||
WorldSimulation World => MarchManager.Instance.World;
|
||||
|
||||
@@ -77,19 +94,22 @@ public class WorldMapController : MonoBehaviour
|
||||
{
|
||||
mapRoot = new GameObject("Map").transform;
|
||||
BuildMap();
|
||||
IntelligenceOverlay.Create(mapRoot, World); // туман и чужие армии (модуль Разведки)
|
||||
SetUpCamera();
|
||||
|
||||
backButton.onClick.AddListener(SceneNavigator.ReturnToCity);
|
||||
if (myCityButton != null) myCityButton.onClick.AddListener(CenterOnMyCity);
|
||||
if (ratingButton != null) ratingButton.onClick.AddListener(ratingPanel.Open);
|
||||
CreateArmiesButton();
|
||||
HookUpComingSoon(worldMapNavButton, "Карта мира");
|
||||
HookUpComingSoon(eventsNavButton, "События");
|
||||
if (circleNavButton != null) circleNavButton.onClick.AddListener(CircleWindow.Open); // модуль Круга
|
||||
HookUpComingSoon(settlementNavButton, "Поселение");
|
||||
sendButton.onClick.AddListener(OnSendPressed);
|
||||
CreateScoutButton();
|
||||
cancelButton.onClick.AddListener(CloseAttackPanel);
|
||||
popupOkButton.onClick.AddListener(ShowNextPopup);
|
||||
soldiersSlider.onValueChanged.AddListener(_ => UpdateSoldiersToSendText());
|
||||
soldiersSlider.onValueChanged.AddListener(_ => { UpdateSoldiersToSendText(); if (attackPanel.activeSelf) RefreshAttackConditions(); });
|
||||
|
||||
attackPanel.SetActive(false);
|
||||
popupPanel.SetActive(false);
|
||||
@@ -104,6 +124,8 @@ public class WorldMapController : MonoBehaviour
|
||||
{
|
||||
if (MarchManager.Instance == null) return;
|
||||
MarchManager.Instance.OnBattleResolved += HandleBattleResolved;
|
||||
MarchManager.Instance.OnGatherStarted += HandleGatherStarted;
|
||||
MarchManager.Instance.OnScoutArrived += HandleScoutArrived;
|
||||
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
|
||||
}
|
||||
|
||||
@@ -111,6 +133,8 @@ public class WorldMapController : MonoBehaviour
|
||||
{
|
||||
if (MarchManager.Instance == null) return;
|
||||
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
|
||||
MarchManager.Instance.OnGatherStarted -= HandleGatherStarted;
|
||||
MarchManager.Instance.OnScoutArrived -= HandleScoutArrived;
|
||||
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
|
||||
}
|
||||
|
||||
@@ -124,7 +148,59 @@ public class WorldMapController : MonoBehaviour
|
||||
// Воины, а не отряды: "В походе: 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})";
|
||||
string slots = army != null ? $"{army.ActiveMarches} из {army.MarchSlots}" : World.Marches.Count.ToString();
|
||||
soldiersText.text = $"Свободно: {GetSoldiersAtHome()} Гарнизон: {garrison} В походе: {inMarches} (отрядов: {slots})";
|
||||
}
|
||||
|
||||
// Кнопка «Армии» -- копия кнопки «Рейтинг» прямо под ней: тот же вид, без правки сцены
|
||||
void CreateArmiesButton()
|
||||
{
|
||||
if (ratingButton == null) return;
|
||||
var copy = Instantiate(ratingButton.gameObject, ratingButton.transform.parent);
|
||||
copy.name = "ArmiesButton";
|
||||
var rt = (RectTransform)copy.transform;
|
||||
var src = (RectTransform)ratingButton.transform;
|
||||
rt.anchoredPosition = src.anchoredPosition - new Vector2(0, src.rect.height + 12);
|
||||
var label = copy.GetComponentInChildren<TMP_Text>();
|
||||
if (label != null) label.text = "Армии";
|
||||
var button = copy.GetComponent<Button>();
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(ArmiesWindow.Open);
|
||||
}
|
||||
|
||||
// Кнопка «Разведка» в окне атаки (решения 31.10, п. 2) -- копия «Отправить». Три кнопки в ряд: Отмена, Разведка, Отправить
|
||||
private Button scoutButton;
|
||||
|
||||
void CreateScoutButton()
|
||||
{
|
||||
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
|
||||
copy.name = "ScoutButton";
|
||||
scoutButton = copy.GetComponent<Button>();
|
||||
scoutButton.onClick.RemoveAllListeners();
|
||||
scoutButton.onClick.AddListener(OnScoutPressed);
|
||||
var label = copy.GetComponentInChildren<TMP_Text>();
|
||||
if (label != null) label.text = "Разведка";
|
||||
|
||||
Place(cancelButton, -290f);
|
||||
Place(scoutButton, 0f);
|
||||
Place(sendButton, 290f);
|
||||
}
|
||||
|
||||
static void Place(Button button, float x)
|
||||
{
|
||||
var rt = (RectTransform)button.transform;
|
||||
rt.anchoredPosition = new Vector2(x, rt.anchoredPosition.y);
|
||||
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 260f), rt.sizeDelta.y);
|
||||
}
|
||||
|
||||
void OnScoutPressed()
|
||||
{
|
||||
if (selectedTarget == null) return;
|
||||
int soldiers = (int)soldiersSlider.value;
|
||||
hintText.text = MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out string error)
|
||||
? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
|
||||
: "Разведка не начата: " + error;
|
||||
CloseAttackPanel();
|
||||
}
|
||||
|
||||
void HookUpComingSoon(Button button, string sectionName)
|
||||
@@ -151,7 +227,7 @@ public class WorldMapController : MonoBehaviour
|
||||
CreateSprite(node.id, PrimitiveSprites.Square, CastleColor, pos, Vector3.one * CastleSize, 0);
|
||||
CreateSprite(node.id + "_Keep", PrimitiveSprites.Square, new Color(0.3f, 0.08f, 0.08f), pos, Vector3.one * CastleSize * 0.5f, 1);
|
||||
CreateLabel(node.displayName, pos + new Vector3(0, CastleSize / 2f + 0.4f, 0), 4f);
|
||||
CreateLabel($"Гарнизон: {node.garrison}", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f);
|
||||
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f)));
|
||||
break;
|
||||
|
||||
case MapNodeType.PlayerCity:
|
||||
@@ -163,7 +239,7 @@ public class WorldMapController : MonoBehaviour
|
||||
|
||||
case MapNodeType.NeighborCity:
|
||||
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);
|
||||
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f)));
|
||||
break;
|
||||
|
||||
case MapNodeType.Camp:
|
||||
@@ -230,16 +306,78 @@ public class WorldMapController : MonoBehaviour
|
||||
nextCampRefresh = Time.unscaledTime + 1f;
|
||||
|
||||
long now = MarchManager.NowMs;
|
||||
RefreshPoints(now);
|
||||
foreach (var (node, label) in garrisonLabels)
|
||||
{
|
||||
string garrison = GarrisonText(node);
|
||||
label.text = node.type == MapNodeType.BossCastle ? garrison : $"{node.displayName}\n{garrison}";
|
||||
}
|
||||
foreach (var (node, sprite, label) in campViews)
|
||||
{
|
||||
long left = World.CampRespawnInMs(node.id, now);
|
||||
sprite.color = left > 0 ? CampClearedColor : CampColor;
|
||||
label.text = left > 0
|
||||
label.text = left > 0 // разбил его сам игрок -- это он знает
|
||||
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
|
||||
: $"{node.displayName}, ур. {node.level}\nГарнизон: {node.garrison}";
|
||||
: $"{node.displayName}, ур. {node.level}\n{GarrisonText(node)}";
|
||||
}
|
||||
}
|
||||
|
||||
// Ресурсные точки: ромб цвета ресурса, подпись -- запас и сколько осталось жить; занятая -- «идёт сбор»
|
||||
void RefreshPoints(long now)
|
||||
{
|
||||
foreach (var p in World.ResourcePoints)
|
||||
{
|
||||
if (!p.active) continue;
|
||||
if (!pointViews.TryGetValue(p.id, out var view))
|
||||
{
|
||||
MapNode node = World.GetNode(p.id);
|
||||
Vector3 pos = ToWorld(node);
|
||||
var sprite = CreateSprite(p.id, PrimitiveSprites.Square, PointColors[p.kind], pos, Vector3.one * PointSize, 0);
|
||||
sprite.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб -- не путается с городом и лагерем
|
||||
var label = CreateLabel("", pos - new Vector3(0, PointSize / 2f + 0.5f, 0), 2.2f);
|
||||
view = (sprite, label);
|
||||
pointViews[p.id] = view;
|
||||
}
|
||||
string name = World.GetNode(p.id).displayName;
|
||||
view.label.text = p.occupiedBy != 0
|
||||
? $"{name}\n<color=#9BE09B>идёт сбор</color>"
|
||||
: IsObserved(World.GetNode(p.id))
|
||||
? $"{name}\n{p.stock} {ResourceGenitive[p.kind]}, ещё {Duration(System.Math.Max(0, p.expireMs - now))}"
|
||||
: $"{name}\n{UnknownText}";
|
||||
}
|
||||
|
||||
// Исчезнувшие точки -- с карты
|
||||
stalePoints.Clear();
|
||||
foreach (var id in pointViews.Keys)
|
||||
{
|
||||
ResourcePoint p = World.GetPoint(id);
|
||||
if (p == null || !p.active) stalePoints.Add(id);
|
||||
}
|
||||
foreach (var id in stalePoints)
|
||||
{
|
||||
Destroy(pointViews[id].sprite.gameObject);
|
||||
Destroy(pointViews[id].label.gameObject);
|
||||
pointViews.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ЗАКРЫТЫЕ СВЕДЕНИЯ (решения 31.10, п. 1) ----------------
|
||||
// Объекты видны всегда как ориентиры, а их гарнизон, запас и срок -- только пока объект в зоне обзора
|
||||
// (модуль Разведки). Без Разведки (старые сцены, тесты) -- всё видно, как раньше.
|
||||
|
||||
const string UnknownText = "<color=#9A9A9A>неизвестно: вне обзора</color>";
|
||||
|
||||
static bool IsObserved(MapNode node)
|
||||
{
|
||||
return node == null || !GameServices.TryGet(out IIntelligence intel)
|
||||
|| intel.GetVisibility(node.x, node.y) == MapVisibility.Observed;
|
||||
}
|
||||
|
||||
static string GarrisonText(MapNode node)
|
||||
{
|
||||
return IsObserved(node) ? $"Гарнизон: {node.garrison}" : $"Гарнизон: {UnknownText}";
|
||||
}
|
||||
|
||||
static string FormatMinutes(long ms)
|
||||
{
|
||||
long sec = (ms + 999) / 1000;
|
||||
@@ -257,6 +395,7 @@ public class WorldMapController : MonoBehaviour
|
||||
{
|
||||
if (attackPanel.activeSelf || popupPanel.activeSelf) return;
|
||||
if (ratingPanel != null && ratingPanel.gameObject.activeSelf) return;
|
||||
if (ArmiesWindow.IsOpen) return;
|
||||
if (!mapCamera.TryGetTap(out Vector3 world)) return;
|
||||
|
||||
MapNode node = FindNodeAt(world);
|
||||
@@ -265,6 +404,8 @@ public class WorldMapController : MonoBehaviour
|
||||
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.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.occupiedBy != 0)
|
||||
hintText.text = $"{node.displayName}: идёт сбор. Войско вернётся само, когда груз будет полон или точка истощится";
|
||||
else if (node.canBeAttacked) OpenAttackPanel(node);
|
||||
}
|
||||
|
||||
@@ -272,8 +413,10 @@ public class WorldMapController : MonoBehaviour
|
||||
{
|
||||
foreach (var node in World.Nodes)
|
||||
{
|
||||
if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.active != true) continue; // исчезнувшая точка
|
||||
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
|
||||
float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize : CitySize;
|
||||
float size = node.type == MapNodeType.BossCastle ? CastleSize : node.type == MapNodeType.Camp ? CampSize
|
||||
: node.type == MapNodeType.ResourcePoint ? PointSize : 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;
|
||||
@@ -287,8 +430,6 @@ public class WorldMapController : MonoBehaviour
|
||||
{
|
||||
selectedTarget = target;
|
||||
int available = GetSoldiersAtHome();
|
||||
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
|
||||
int travelSeconds = Mathf.CeilToInt(MarchRules.GetTravelTimeMs(home, target) / 1000f);
|
||||
|
||||
attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}"
|
||||
: target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}"
|
||||
@@ -296,10 +437,13 @@ public class WorldMapController : MonoBehaviour
|
||||
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)}";
|
||||
shownObserved = IsObserved(target);
|
||||
attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target)
|
||||
: (shownObserved
|
||||
? $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})"
|
||||
: "Гарнизон неизвестен: цель вне зоны обзора") +
|
||||
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
|
||||
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
||||
RefreshAttackConditions();
|
||||
|
||||
soldiersSlider.minValue = available > 0 ? 1 : 0;
|
||||
@@ -307,8 +451,11 @@ public class WorldMapController : MonoBehaviour
|
||||
soldiersSlider.wholeNumbers = true;
|
||||
soldiersSlider.value = available;
|
||||
soldiersSlider.interactable = available > 0;
|
||||
sendButton.interactable = available > 0;
|
||||
bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots;
|
||||
sendButton.interactable = available > 0 && slotFree;
|
||||
if (scoutButton != null) scoutButton.interactable = available > 0 && slotFree;
|
||||
UpdateSoldiersToSendText();
|
||||
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
|
||||
|
||||
attackPanel.SetActive(true);
|
||||
}
|
||||
@@ -318,12 +465,47 @@ public class WorldMapController : MonoBehaviour
|
||||
void RefreshAttackConditions()
|
||||
{
|
||||
shownConditions = MarchManager.PlayerConditions();
|
||||
attackInfoText.text = attackInfoBase + ConditionsWarning(shownConditions);
|
||||
bool point = selectedTarget != null && selectedTarget.type == MapNodeType.ResourcePoint;
|
||||
attackInfoText.text = attackInfoBase + PathText() + (point ? "" : ConditionsWarning(shownConditions)); // голод -- про бой
|
||||
}
|
||||
|
||||
// Путь в одну сторону -- по скорости состава (регламент 31.10), реальное время; занятые слоты похода
|
||||
string PathText()
|
||||
{
|
||||
if (selectedTarget == null) return "";
|
||||
int[] units = PreviewUnits((int)soldiersSlider.value);
|
||||
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
|
||||
string slots = GameServices.TryGet(out IArmy army) && army.ActiveMarches >= army.MarchSlots
|
||||
? $"\n<color=#E06A5A>Все слоты похода заняты ({army.ActiveMarches} из {army.MarchSlots}) -- дождитесь возвращения войска</color>"
|
||||
: "";
|
||||
long path = World.TravelTimeMs(home, selectedTarget, units);
|
||||
// До отправки -- длительность всего задания (регламент 31.10). Для сбора она в прогнозе ниже
|
||||
string total = selectedTarget.type == MapNodeType.ResourcePoint ? "" : $", туда и обратно ~{Duration(path * 2)}";
|
||||
return $"\nПуть в одну сторону: {Duration(path)}{total}" + slots;
|
||||
}
|
||||
|
||||
// 3 900 000 мс -> "1 ч 5 мин"; меньше минуты -- в секундах
|
||||
static string Duration(long ms)
|
||||
{
|
||||
long sec = (ms + 999) / 1000;
|
||||
if (sec < 60) return $"{sec} сек";
|
||||
long min = (sec + 59) / 60;
|
||||
return min < 60 ? $"{min} мин" : min % 60 == 0 ? $"{min / 60} ч" : $"{min / 60} ч {min % 60} мин";
|
||||
}
|
||||
|
||||
// Кого Армия заберёт в поход: свободных воинов по составу (гарнизон остаётся)
|
||||
int[] PreviewUnits(int soldiers)
|
||||
{
|
||||
if (soldiers <= 0 || !GameServices.TryGet(out IArmy army)) return null;
|
||||
var troops = new int[TroopType.Count];
|
||||
for (int i = 0; i < TroopType.Count; i++) troops[i] = army.GetTroops(i) - army.GetGarrison(i);
|
||||
return ArmyMath.TakeProportional(troops, soldiers);
|
||||
}
|
||||
|
||||
void RefreshOpenAttackPanel()
|
||||
{
|
||||
if (!attackPanel.activeSelf || selectedTarget == null) return;
|
||||
if (IsObserved(selectedTarget) != shownObserved) { OpenAttackPanel(selectedTarget); return; } // цель вошла в обзор или вышла
|
||||
if (MarchManager.PlayerConditions() == shownConditions) return;
|
||||
RefreshAttackConditions();
|
||||
UpdateSoldiersToSendText();
|
||||
@@ -346,15 +528,41 @@ public class WorldMapController : MonoBehaviour
|
||||
: "Дома нет солдат";
|
||||
}
|
||||
|
||||
// Ресурсная точка: запас, срок, добыча и груз на воина (регламент 31.10)
|
||||
string PointInfo(MapNode target)
|
||||
{
|
||||
ResourcePoint p = World.GetPoint(target.id);
|
||||
if (p == null) return "Этой точки больше нет";
|
||||
var r = World.PointRules;
|
||||
if (!shownObserved)
|
||||
return "Запас и срок неизвестны: точка вне зоны обзора\n" +
|
||||
$"Воин добывает {r.MiningMilliPerGameHour[p.kind] / 1000f:0.#} в игровой час (5 мин), несёт {r.CargoPerSoldier[p.kind]}; осадные не несут";
|
||||
return $"Запас: {p.stock} {ResourceGenitive[p.kind]}, точка исчезнет через {Duration(System.Math.Max(0, p.expireMs - MarchManager.NowMs))}\n" +
|
||||
$"Воин добывает {r.MiningMilliPerGameHour[p.kind] / 1000f:0.#} в игровой час (5 мин), несёт {r.CargoPerSoldier[p.kind]}; осадные не несут";
|
||||
}
|
||||
|
||||
// Прогноз сбора: сколько унесут и сколько займёт весь поход
|
||||
string GatherPreview(int soldiers)
|
||||
{
|
||||
int[] units = PreviewUnits(soldiers);
|
||||
ResourcePoint p = World.GetPoint(selectedTarget.id);
|
||||
if (units == null || p == null) return "";
|
||||
if (!shownObserved) return "сколько унесут -- неизвестно: точка вне обзора";
|
||||
MapNode home = World.GetNode(WorldMapLayout.PlayerCityId);
|
||||
long path = World.TravelTimeMs(home, selectedTarget, units);
|
||||
World.PreviewGather(p.id, units, MarchManager.NowMs + path, out int amount, out long gatherMs);
|
||||
if (amount <= 0) return "<color=#E06A5A>унесут 0 -- точка исчезнет раньше или некому нести</color>";
|
||||
return $"унесут {amount} {ResourceGenitive[p.kind]}, сбор {Duration(gatherMs)}, весь поход ~{Duration(path * 2 + gatherMs)}";
|
||||
}
|
||||
|
||||
// Ожидаемый исход боя: тот же расчёт, что сделает модуль Боя, с составом, который заберёт Армия
|
||||
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 (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers);
|
||||
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
|
||||
if (units == null) return "";
|
||||
if (!shownObserved) return "прогноз боя недоступен: гарнизон цели неизвестен";
|
||||
|
||||
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
|
||||
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
|
||||
@@ -368,8 +576,17 @@ public class WorldMapController : MonoBehaviour
|
||||
string chance = f.Chance == BattleChance.Advantage ? "<color=#7CD67C>преимущество</color>"
|
||||
: f.Chance == BattleChance.HighRisk ? "<color=#E06A5A>высокий риск поражения</color>"
|
||||
: "<color=#E8C15A>исход неясен</color>";
|
||||
// Решение №11: качественный риск и рассчитанный шанс победы
|
||||
return $"сила {Range(f.AttackerPowerMin, f.AttackerPowerMax)} против {Range(f.DefenderPowerMin, f.DefenderPowerMax)} -- " +
|
||||
$"{chance}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}";
|
||||
$"{chance}, шанс победы {ChanceText(f.WinChancePermille)}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}";
|
||||
}
|
||||
|
||||
// 273 десятых процента -> "27%"; около краёв -- с десятыми, чтобы 99,6% не выглядело как 100%
|
||||
static string ChanceText(int permille)
|
||||
{
|
||||
if (permille > 0 && permille < 10) return "<1%";
|
||||
if (permille > 990 && permille < 1000) return ">99%";
|
||||
return (permille + 5) / 10 + "%";
|
||||
}
|
||||
|
||||
// Диапазон "120-131" (числа x100 -> обычные); одинаковые края -- одно число
|
||||
@@ -436,6 +653,8 @@ public class WorldMapController : MonoBehaviour
|
||||
|
||||
foreach (var march in World.Marches)
|
||||
{
|
||||
// Чужие армии (патрули соседей) рисует IntelligenceOverlay -- только те, что видит Разведка
|
||||
if (march.ownerId != MarchManager.LocalPlayerId) continue;
|
||||
if (!marchViews.TryGetValue(march.id, out MarchView view))
|
||||
{
|
||||
view = MarchView.Create(mapRoot, march);
|
||||
@@ -475,7 +694,7 @@ public class WorldMapController : MonoBehaviour
|
||||
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
|
||||
string body = $"{target.displayName}\n" +
|
||||
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
|
||||
$"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" +
|
||||
$"Твои потери: {march.soldiers - march.survivors}{WoundedText(march)}, потери врага: {march.defenderLosses}\n" +
|
||||
(march.attackerPower > 0 || march.defenderPower > 0
|
||||
? $"Сила {march.attackerPower / 100} : {march.defenderPower / 100}, " +
|
||||
$"удача {Luck(march.attackerLuck)} / {Luck(march.defenderLuck)}" +
|
||||
@@ -489,8 +708,40 @@ public class WorldMapController : MonoBehaviour
|
||||
EnqueuePopup(title, body);
|
||||
}
|
||||
|
||||
void HandleScoutArrived(MarchData march)
|
||||
{
|
||||
string target = World.TargetName(march);
|
||||
hintText.text = march.targetEmpty
|
||||
? $"Разведка: «{target}» уже нет, войско возвращается"
|
||||
: $"Разведка дошла до цели «{target}» и возвращается домой";
|
||||
}
|
||||
|
||||
// «(из них ранены 12 -- придут в госпиталь)»: раненые идут домой с войском
|
||||
static string WoundedText(MarchData march)
|
||||
{
|
||||
int w = 0;
|
||||
if (march.woundedUnits != null) foreach (var u in march.woundedUnits) w += u;
|
||||
return w > 0 ? $" (ранены {w} -- придут в госпиталь)" : "";
|
||||
}
|
||||
|
||||
void HandleGatherStarted(MarchData march)
|
||||
{
|
||||
string target = World.TargetName(march);
|
||||
if (march.targetEmpty)
|
||||
EnqueuePopup("ТОЧКИ НЕТ", $"{target} исчезла или занята\nСбора не было, домой идут все {march.soldiers} воинов");
|
||||
else
|
||||
hintText.text = $"Войско начало сбор: {target}, унесёт {march.cargo}";
|
||||
}
|
||||
|
||||
void HandleMarchReturned(MarchData march)
|
||||
{
|
||||
if (march.scout)
|
||||
{
|
||||
EnqueuePopup("РАЗВЕДКА ВЕРНУЛАСЬ", $"{World.TargetName(march)}: " +
|
||||
(march.targetEmpty ? "цели уже не было" : "цель разведана") +
|
||||
$"\nВ город вернулись все {march.survivors} воинов, боя не было");
|
||||
return;
|
||||
}
|
||||
string body = $"В город вернулись {march.survivors} солдат\n" +
|
||||
$"Добыча: {MarchManager.FormatLoot(march.loot)}";
|
||||
EnqueuePopup("ВОЙСКО ДОМА", body);
|
||||
|
||||
+59
-1
@@ -15,6 +15,7 @@
|
||||
| `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). |
|
||||
| `TroopsInMarches` | Воинов в походах. |
|
||||
| `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. |
|
||||
| `MarchSlots`, `ActiveMarches` | Слоты похода (регламент 31.10): сколько походов может идти одновременно (база 2 из `MarchConfig`, «Военная логистика» — до 7, позже) и сколько идёт сейчас. `TakeForMarch` отказывает, если все слоты заняты. Походы из старых сохранений сверх лимита дойдут, новых не будет. |
|
||||
| `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. |
|
||||
| `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. |
|
||||
| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). |
|
||||
@@ -23,9 +24,55 @@
|
||||
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. |
|
||||
| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. |
|
||||
| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. |
|
||||
| `WoundedTotal`, `GetWoundedWaiting(type)`, `Healing`, `HospitalBeds` | Раненые всего; ждут места по типам; группы на лечении (`HealingInfo`); мест в госпитале сейчас. |
|
||||
| `ReturnUnits(marchId, survivorUnits, woundedUnits, nowMs)` | Выжившие — в город, раненые — в госпиталь в момент `nowMs`. Только известный поход, один раз. |
|
||||
| `ProcessHealing(nowMs)` | Закончить лечение по сроку (и офлайн), положить ждущих на освободившиеся места; `SoldiersHealedEvent` — один раз на группу. |
|
||||
| `Training` | Очередь обучения: заказы (`TrainingOrderInfo`: номер, тип, сколько, начало и конец в мс часов игры). |
|
||||
| `CanQueueTraining(type, count, queueSize, out error)` | Можно ли поставить заказ: очередь не заполнена, жителей хватает (Население). Ничего не меняет. |
|
||||
| `TryQueueTraining(type, count, durationMs, queueSize, nowMs, out error)` | Поставить заказ в конец очереди; люди уходят из жителей в Казармы (`IPopulation.TryRecruit`). `false` — ничего не изменилось. |
|
||||
| `ProcessTraining(nowMs)` | Закончить заказы, срок которых наступил (и офлайн): воины переходят в Армию через `IPopulation.CompleteTraining`, каждый заказ — один раз. |
|
||||
| `FinishAllTraining(nowMs)` | Ускорение: все заказы готовы сейчас. |
|
||||
|
||||
Получить: `GameServices.Get<IArmy>()`. Регистрирует `App/GameModules`. `GameProgress` (обучение, кампания, арена, сила армии) и панели работают с войсками только через этот контракт.
|
||||
|
||||
## Раненые и госпиталь
|
||||
|
||||
Решения по 8 вопросам Саши от 31.10, п. 7, регламент 31.10. **Раненых хранит и лечит Армия.**
|
||||
|
||||
| Кто | Что делает |
|
||||
| --- | --- |
|
||||
| Бой | Делит выбывших на погибших и раненых (Battle.md). |
|
||||
| Мир | Раненые идут домой вместе с войском (`MarchData.woundedUnits`). |
|
||||
| Армия | Принимает раненых при возвращении, кладёт в госпиталь, лечит, возвращает в город. Сохраняет. |
|
||||
| Население | Считает раненых людьми: `Soldiers` = в городе + в походах + раненые. Погибшие уходят из людей, вылеченные — не новые люди. |
|
||||
| Город | Здание «Госпиталь» (`Meta/Build/Hospital.cs`): уровни I–V, места 20 / 30 / 40 / 50 / 60 (`HospitalConfig.bedsByLevel`, ТЕСТ), считается самый прокачанный госпиталь. Места — Армии через `IHospital.Beds`. Без госпиталя — 5 временных мест (`temporaryBeds`, решения 01.11, п. 3); после постройки — штатные, не вместе с временными. |
|
||||
| Экономика | Цена лечения — `IEconomy.TrySpend` целиком: золото за воина 1 / 1 / 2 / 3 (пехота, лучники, кавалерия, осадные; решения 01.11, п. 3). Нет золота — раненые ждут. |
|
||||
|
||||
- Таблица `Assets/Resources/HospitalConfig.asset` (ТЕСТ): `bedsByLevel` 20…60, `temporaryBeds` 5 (без госпиталя), постройка — Ратуша 1, 250 дерева, 150 камня, `healGameHours` 4 / 4 / 6 / 8 игровых часа (пехота, лучники, кавалерия, осадные; 1 игровой час = 5 реальных минут), цена за воина `healGoldPerSoldier` 1 / 1 / 2 / 3, `healFoodPerSoldier` 0. Цена улучшения госпиталя II–V не утверждена — пока общая цена улучшения зданий.
|
||||
- Раненые ложатся на места по типам по порядку; каждая группа лечится своё время и возвращается в город целиком.
|
||||
- **Мест не хватает — лишние ждут** (ТЕСТ, решение Саши 01.11; регламент не задаёт). Место освободилось — ждущие ложатся в тот же момент, в том числе пока игра закрыта.
|
||||
- Не хватает ресурсов на лечение — группа ждёт, ничего не списано.
|
||||
- Повторное возвращение похода или повторный расчёт никого не создают и не лечат дважды.
|
||||
- Окно «Войска»: раненые (лечатся / ждут места), мест в госпитале, когда вылечатся ближайшие. Окно возвращения и итог боя на карте показывают, сколько ранено.
|
||||
- Окно госпиталя (тап по зданию): мест, кто лечится и сколько осталось, кто ждёт места, улучшение до V уровня (цена — общая цена улучшения из `EconomyConfig`).
|
||||
- Госпиталь улучшили — ждущие ложатся сразу (в течение секунды).
|
||||
- **Дальше:** герои — отдельная очередь лечения (регламент), когда появятся герои.
|
||||
|
||||
## Очередь обучения
|
||||
|
||||
Решения по 8 вопросам Саши от 31.10, п. 8: **единственный владелец очереди — Армия.** Раньше её вёл `GameProgress` (`progress.json`, время по настоящим часам) — перенесено.
|
||||
|
||||
| Кто | Что делает |
|
||||
| --- | --- |
|
||||
| Армия | Заказы по порядку (новый начинается, когда закончится последний), сроки, завершение — один раз на заказ (`TroopsTrainedEvent`), сохранение. |
|
||||
| Население | Свободные жители и резерв (`Recruitable`, `CivilianReserve`), уход людей в Казармы (`TryRecruit`) и переход в воины (`CompleteTraining`). |
|
||||
| Слой приложения (`GameProgress`) | Есть ли Казармы и открыт ли тип войск, длина очереди (`Barracks.QueueSize`), цена — через Экономику (если заказ не встал — цена возвращается), окна, статистика заданий по `TroopsTrainedEvent`. |
|
||||
|
||||
- Время — часы игры (`GameClock.UnixNowMs`): перемотка «DEV +1 час» и офлайн досчитывают обучение так же, как марши. Раньше обучение шло по настоящим часам и перемотки не замечало.
|
||||
- Повторный расчёт или загрузка не обучают заказ второй раз: он удаляется из очереди в момент завершения.
|
||||
- Население не нашло новобранцев заказа (испорченное сохранение) — заказ снимается без воинов: люди не появляются из ниоткуда, очередь не встаёт.
|
||||
- **Перенос старой очереди:** после загрузки `App/GameModules` один раз переносит заказы из `progress.json` (`Data.training`) в Армию с тем же оставшимся временем и признаком «люди взяты». Из `progress.json` они удаляются только после записи `save.json`.
|
||||
|
||||
## Правило «один солдат в одном месте»
|
||||
|
||||
Воин либо в городе, либо в походе. Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам. Тест `ArmyService_MarchNeverCreatesOrLosesSoldiers` проверяет, что «дома + в походах» всегда равно общему числу.
|
||||
@@ -50,6 +97,7 @@
|
||||
## События
|
||||
|
||||
- **Публикует:** `TroopsChangedEvent` (воинов в городе, из них в гарнизоне, и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD.
|
||||
- **Публикует:** `TroopsTrainedEvent` (номер заказа, тип, сколько, когда были готовы) — один раз на заказ. По нему `GameProgress` ведёт статистику заданий и показывает «Обучено».
|
||||
- **Слушает:** ничего.
|
||||
|
||||
## Сохранение
|
||||
@@ -60,11 +108,16 @@
|
||||
troops=<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops)
|
||||
march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
|
||||
train=<номер>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
|
||||
trainNext=<номер следующего заказа>
|
||||
wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
|
||||
heal=<номер>|<тип>|<сколько>|<начало, мс>|<конец, мс>
|
||||
healNext=<номер следующей группы лечения>
|
||||
```
|
||||
|
||||
Старое сохранение без строки `garrison` читается: гарнизона нет.
|
||||
|
||||
Сброс (`ResetState`): войск, гарнизона и походов нет.
|
||||
Сброс (`ResetState`): войск, гарнизона, походов, заказов обучения и раненых нет.
|
||||
|
||||
**Перенос из старых сохранений:**
|
||||
- Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`.
|
||||
@@ -95,6 +148,11 @@ march=<id похода>:<пехота>,<лучники>,<кавалерия>,<о
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 3: 5 временных мест до постройки госпиталя, цена лечения 1 / 1 / 2 / 3 золота за воина. |
|
||||
| 01.11.2026 | Claude | Здание «Госпиталь» в городе: уровни I–V, 20–60 мест, окно с ранеными и улучшением; места Армии — через `IHospital` (`HospitalBeds`). Без госпиталя мест нет (`temporaryBeds` = 0). Тест `Hospital_BedsFromBuilding_UpgradeFreesWaiting`. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и лечение у Армии — `WoundedTotal`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`, `SoldiersHealedEvent`; места от `IHospital` (пока нет здания — `HospitalConfig.temporaryBeds`), лишние ждут, цена через Экономику (не утверждена). Сохранение `wounded=`, `heal=`. Тесты: места и сроки, однократность, люди в Населении, сохранение и цена. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 8: очередь обучения перенесена из `GameProgress` в Армию (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`, `TroopsTrainedEvent`, строки `train=` в сохранении), время — часы игры, перенос старой очереди из `progress.json`. Изменены `GameProgress`, `TroopsPanel`, `Barracks`, `BuildingWindow` (код Вальдемара, с разрешения команды). Тесты: порядок и однократность, лимит и ускорение, люди из Населения и обратно, сохранение и перенос. |
|
||||
| 31.10.2026 | Claude | Слоты похода: `MarchSlots`, `ActiveMarches`; `ArmyService(baseMarchSlots)`, база 2 из таблицы `MarchConfig`; `TakeForMarch` отказывает при занятых слотах. Тест `ArmyService_MarchSlots`. |
|
||||
| 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`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. |
|
||||
|
||||
+34
-28
@@ -28,14 +28,15 @@
|
||||
| `AttackerLuckPermille`, `DefenderLuckPermille` | Выпавшая случайность сторон в десятых долях процента: +23 = +2,3%. |
|
||||
| `AttackerConditionPercent`, `DefenderConditionPercent` | Сколько силы оставили состояния: 100 — без штрафов, 50 — голод, 40 — голод и усталость. |
|
||||
| `AttackerLosses`, `DefenderLosses`, `Survivors` | Выбывшие (погибшие + раненые) и выжившие атакующие — всего. |
|
||||
| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Раненых пока нет (нули): доля не утверждена. |
|
||||
| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Доля погибших среди выбывших — от соотношения сил своей стороны (см. «Погибшие и раненые»). |
|
||||
| `DefenderLossUnits` | Выбывшие защитники по типам — для старого кода. |
|
||||
|
||||
| `BattleForecast` | Что это |
|
||||
| --- | --- |
|
||||
| `Chance` | `Advantage` — победа при любой случайности, `Uncertain` — решает случайность, `HighRisk` — поражение при любой случайности. |
|
||||
| `WinChancePermille` | Шанс победы нападающего в десятых долях процента (решение №11). Точный: перебираются все 101 × 101 равновероятных сочетаний удачи сторон. |
|
||||
| `AttackerPowerMin/Max`, `DefenderPowerMin/Max` | Сила сторон при крайних значениях случайности. |
|
||||
| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего. |
|
||||
| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего, с учётом округления потерь (№13). |
|
||||
|
||||
Регистрирует `App/GameModules`: числа боя — из таблицы `BattleConfig`, сила воина каждого типа — из `GameData.Troops` (4 / 5 / 9 / 16). Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
|
||||
|
||||
@@ -54,34 +55,38 @@
|
||||
| Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. |
|
||||
| Осадные | Против стен +50%, в поле −25%. |
|
||||
| Стены | Защитники за стенами сильнее на 20%. |
|
||||
| Случайность, ±5% | Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется последней: после контр, стен и бонусов. |
|
||||
| Удача, ±5% | Решение №12. Каждой стороне от −5% до +5% (с шагом 0,1%), отдельно. Считается из номера боя (`Seed`, алгоритм SplitMix64) — одинаково на любой платформе. Применяется после всех модификаторов. Не прокачивается. |
|
||||
| Без промежуточного округления | Решение №12. Сила считается точной дробью (`BigInteger`): контра по составу врага, бонусы, голод и удача не округляются по дороге. Округляется один раз — только число для показа. Пример: 3 пехоты против 1 кавалерии и 2 пехоты — сила 13,00 (раньше 12,96). |
|
||||
| Голод, −50% | Утверждено 27.10.2026. Сила голодной стороны ×50%. Проверяется на момент боя. Действует и на нападающих, и на защитников. |
|
||||
| Усталость, −20% | Утверждено 27.10.2026. Сила ×80%. Правило уже в модуле, но **пока никто её не выставляет**: не утверждено, как учитывать смешивание свежих и уставших войск. |
|
||||
| Голод и усталость вместе | Последовательно: 50% × 80% = **40%** силы (а не 30%). |
|
||||
| Порядок | Контры и осадные → бонусы стороны → состояния (голод, усталость) → случайность. |
|
||||
| Исход | Побеждает тот, у кого итоговая сила больше; ничья — защитнику. |
|
||||
| Потери проигравшего | Половина каждого типа. |
|
||||
| Потери победителя | Половина × (сила проигравшего / сила победителя): чем ровнее бой, тем больше потерь, но не больше половины. |
|
||||
| Потери (решение №14, регламент 31.10) | Доля выбывших каждого типа зависит от соотношения итоговых сил (сильная / слабая). Опорные точки (ТЕСТ): **1:1 — 45% / 45%**, **3:1 — 16% / 74%**, **5:1 — 11% / 88%**. Между точками — плавно (линейно по соотношению), без порогов. Одинаково для PvE, PvP и Круга. Разгром (решения по 8 вопросам Саши, 31.10, п. 5): **7:1 — 92%**, **10:1 — 95%** у слабой стороны, дальше плавно к пределу 99% (`v = 99% − 4% × 10 / R`: 20:1 — 97%, 100:1 — 98,6%), 100% не достигается. Небольшая армия всё равно может погибнуть целиком из-за округления шансом (№13). Сильная сторона (решения по отчёту Саши, 01.11, п. 1): 5:1 — 11%, плавно до **2% при 10:1**, дальше 2%. Точки — в `BattleConfig` (`lossRatioPermille`, `strongLossPermille`, `weakLossPermille`, предел `weakLossMaxPermille`). |
|
||||
| Округление потерь | Решение №13. Целая часть гарантирована, дробная — шанс ещё одной потери. Бросок — из отдельного потока случайности (своя «соль» на сторону и тип войск, не пересекается с удачей), воспроизводим по номеру боя. Одиночка-проигравший (потеря 0,5) гибнет примерно в половине боёв; 3 проигравших (1,5) теряют 1 или 2. |
|
||||
|
||||
Примеры (замок Змея Горыныча, гарнизон 20: пехота 8, лучники 6, кавалерия 4, осадные 2, за стенами):
|
||||
|
||||
| Атакующие | Сила | Исход | Вернутся |
|
||||
| Атакующие | Исход | Вернутся (в среднем) | Враг теряет из 20 |
|
||||
| --- | --- | --- | --- |
|
||||
| 30 пехоты | 126 против 155 | поражение | 15 |
|
||||
| 40 пехоты | 168 против 155 | победа | 22 |
|
||||
| 30 смешанных (12 / 8 / 6 / 4) | 248 против 154 | победа | — |
|
||||
| 30 пехоты | поражение | 15,5 | 8,3 |
|
||||
| 40 пехоты | победа в 97% боёв | 22,5 | 9,2 |
|
||||
| 30 смешанных (12 / 8 / 6 / 4) | победа | 19,1 | 10,8 |
|
||||
| 78 смешанных | победа | 67,7 | 16,4 |
|
||||
|
||||
**Прогноз с учётом случайности** (тот же замок, 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% |
|
||||
| 30 пехоты | высокий риск поражения | 0% | 119–132 против 147–163 | 14–16 |
|
||||
| 36 пехоты | исход неясен | 26,5% | 143–158 против 147–163 | 16–18 |
|
||||
| 40 пехоты | исход неясен | 97,4% | 159–176 против 147–163 | 16–19 |
|
||||
| 45 пехоты | преимущество | 100% | 179–198 против 147–163 | 18–20 |
|
||||
| 30 смешанных (12 / 8 / 6 / 4) | преимущество | 100% | 236–260 против 147–162 | 9–14 |
|
||||
|
||||
Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 — по-прежнему победа и 20 вернувшихся.
|
||||
Шанс проверен тестом `Battle_WinChance_MatchesRealBattles`: совпадает с долей побед в 20 000 настоящих боёв с точностью ±2%.
|
||||
|
||||
Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 (1,5:1) — победа, возвращаются 18–19.
|
||||
|
||||
## События
|
||||
|
||||
@@ -94,22 +99,18 @@
|
||||
## Решения и план
|
||||
|
||||
- **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения».
|
||||
- **Открыто: прогноз.** «Исход неясен» охватывает и 27%, и 97% побед (таблица выше). Показывать ли игроку шанс победы в процентах — решает команда (посчитать его можно точно).
|
||||
- **Решено комплектом 29.10.2026 и сделано 30.10.2026:** шанс победы в прогнозе (№11), сила без промежуточного округления (№12), округление потерь шансом (№13), голод на момент боя по истории голода Населения.
|
||||
- **Сделано 28.10.2026: голод в бою.** Голод игрока берётся из модуля Населения (`IPopulation.IsStarving`) в момент боя. Пример: 40 пехоты берут замок Змея Горыныча (168 против 155), голодные — нет (84 против 155). Числа — `famineStrengthPercent` и `fatigueStrengthPercent` в `BattleConfig` (версия правил 2).
|
||||
- **Ограничение голода в офлайне:** если бой прошёл, пока игра была закрыта, берётся голод на момент входа в игру — истории голода у модуля Населения нет. Точно — когда бои будет считать сервер или Население будет хранить, когда начался и кончился голод (договориться с экономикой).
|
||||
- **Голод в офлайн-бою:** Население хранит историю голода (`IPopulation.WasStarvingAt`, 48 часов, точность — шаг расчёта 10 минут). Перед боем, прошедшим в прошлом, мир просит Население досчитать время, затем спрашивает голод на момент прихода войска.
|
||||
- **Голод гарнизона** пока не нужен: на город игрока никто не нападает. Правило защитника уже есть (`DefenderConditions`).
|
||||
- **Открыто: порядок бонусов и случайности.** Сейчас случайность применяется последней, после бонусов стороны.
|
||||
- **Порядок бонусов и случайности** решён (№12): удача — после всех модификаторов.
|
||||
- **Номер боя в мультиплеере** должен выдавать сервер: иначе клиент мог бы подбирать удачный.
|
||||
- **Командный план и ревью от 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` правило не фиксирует.
|
||||
- **Малые отряды** решены (№13): одиночка-проигравший гибнет с шансом по дробной части потерь.
|
||||
- **Сделано 31.10.2026: кривая потерь (№14)** по опорным точкам регламента 31.10.
|
||||
- **Сделано 01.11.2026: разгром дальше 5:1** по решениям 31.10, п. 5. Потери сильной стороны после 5:1 — решены 01.11 (до 2% при 10:1).
|
||||
- **Сделано 01.11.2026: погибшие и раненые** (регламент 31.10, решения по 8 вопросам Саши, п. 7). Из выбывших стороны погибает доля по её соотношению сил (своя / чужая): **1:5 — 70%**, **1:2 — 45%**, **1:1 — 30%**, **3:1 и выше — 20%**, между точками плавно; остальные — раненые. Дробная часть — бросок из отдельного потока (не удачи и не потерь). Точки — `BattleConfig` (`deadRatioPermille`, `deadSharePermille`). Версия правил 6. Раненых хранит и лечит Армия (Army.md).
|
||||
- **Открыто:** «пропавшие без вести» (регламент) — отдельно не считаются.
|
||||
|
||||
- **Утвердить формулу** (контры, стены, осадные, потери) или поправить числа в `BattleSystem.cs`.
|
||||
- **Бонус героя** пока не передаётся в поход: `GameProgress.ArmyBonus` и сила героя в слое приложения. Нужен способ передать их в `AttackerBonusPercent` — например, через новый контракт или поле похода.
|
||||
@@ -120,6 +121,11 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 1: потери сильной стороны 5:1 → 10:1 плавно с 11% до 2%, дальше 2% (`strongLossPermille` 450 / 160 / 110 / 74 / 20). Версия правил 7. |
|
||||
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): выбывшие делятся на погибших и раненых по соотношению сил стороны — 70% / 45% / 30% / 20% погибших при 1:5 / 1:2 / 1:1 / 3:1. Таблица в `BattleConfig`, версия правил 6, тест `Battle_DeadAndWounded_ShareBySideRatio`. |
|
||||
| 01.11.2026 | Claude | Разгром дальше 5:1 (решения 31.10, п. 5): точки 7:1 — 92% и 10:1 — 95%, дальше слабая сторона плавно стремится к 99% (`weakLossMaxPermille`). Версия правил 5. Тест кривой продлён до 30:1. |
|
||||
| 31.10.2026 | Claude | Кривая потерь (решение №14, регламент 31.10): доля выбывших от соотношения итоговых сил по опорным точкам 1:1 — 45% / 45%, 3:1 — 16% / 74%, 5:1 — 11% / 88%, между ними плавно; вместо «проигравший 50%». Точки в `BattleConfig`, неверная таблица — значения по умолчанию. Версия правил 4. Тест `Battle_LossCurve_ControlPointsAndSmoothness`; тесты округления пересчитаны под кривую. |
|
||||
| 30.10.2026 | Claude | Комплект требований 29.10, решения №11–13: точный шанс победы в прогнозе (`BattleForecast.WinChancePermille`); сила — точной дробью без промежуточного округления; потери — целая часть гарантирована, дробная — шанс из отдельного потока случайности. Версия правил 3. Тесты: шанс совпадает с настоящими боями, сила без округления, округление потерь. |
|
||||
| 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%, бой числом. Проверяются исход и арифметика (нет отрицательных, выжившие + погибшие = ушедшие). Правило округления потерь — открытый вопрос. |
|
||||
|
||||
+15
-1
@@ -33,7 +33,11 @@
|
||||
| `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. |
|
||||
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. |
|
||||
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
|
||||
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
|
||||
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход; начать поход «Разведка» (`TryStartScout`). |
|
||||
| `IWorldMap` | Мир (`MarchManager`) | Для Разведки: границы карты, где поселение, все армии в момент времени (`ArmyOnMap`). |
|
||||
| `IIntelligence` | Разведка (`IntelligenceService`) | Туман (`GetVisibility`), «Обзор», известные чужие армии (`KnownContact`), `ObserveUntil`. |
|
||||
| `IResourceProtection` | Город — Склады и Амбары (`App/ResourceProtection`, регистрирует `App/GameModules`) | Сколько ресурса спрятано от грабежа (`Protected`) и сколько можно унести в набеге (`Lootable`; кристаллы — только с технологией «Грабёж»: `Lootable(type, attackerHasPlunder)`). 01.11 / 04.11, ТЕСТ. |
|
||||
| `IHospital` | Город — здание «Госпиталь» (`HospitalBeds`, регистрирует `App/GameModules`) | Мест для лечения (`Beds`) по уровню самого прокачанного госпиталя; 0 — госпиталя нет. |
|
||||
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`: состав сторон по типам, стены, бонусы. |
|
||||
| `IChat` | Чат (`ChatService`) | История каналов, можно ли писать в канал, отправка сообщения от локального игрока. |
|
||||
| `ICircle` | Круг (`CircleService`) | Круг игрока, список Кругов; основать, вступить, выйти. |
|
||||
@@ -58,8 +62,18 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Событие `GameTimeSimulatedEvent` (расчёт дошёл до момента — для стройки в офлайне). `IResourceProtection.Lootable(type, attackerHasPlunder)` — кристаллы и технология «Грабёж». Существующие контракты не менялись. |
|
||||
| 01.11.2026 | Claude | Контракт `IResourceProtection` (защита ресурсов от грабежа): для будущих набегов на города — Бой и Мир берут отсюда, сколько можно унести. |
|
||||
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. |
|
||||
| 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). |
|
||||
| 01.11.2026 | Claude | `IWorld.TryStartScout`; `IsScout` у `MarchStartedEvent` и `MarchReturnedEvent` (решения 31.10, п. 2). |
|
||||
| 31.10.2026 | Claude | Разведка М1: контракты `IWorldMap`, `IIntelligence` (`ArmyOnMap`, `MapVisibility`, `KnownContact`), события `WorldAdvancingEvent`, `ArmySpottedEvent`, `ArmyIdentifiedEvent`, `ArmyLostContactEvent`. |
|
||||
| 31.10.2026 | Claude | `IPopulation.CompleteTraining(type, n)` — передача новобранцев в Армию одним действием. |
|
||||
| 31.10.2026 | Claude | `IPopulation.Workforce` — все жители (ограничения «40% всех людей» больше нет), `CivilianReserve` — по уровню Ратуши. Население — самостоятельный модуль. |
|
||||
| 31.10.2026 | Claude | `MarchReturnedEvent.IsGather` — поход на ресурсную точку. |
|
||||
| 31.10.2026 | Claude | `IArmy.MarchSlots`, `IArmy.ActiveMarches` — слоты похода. |
|
||||
| 30.10.2026 | Claude | События похода: `TimeMs` (когда случилось по часам игры) у `MarchStartedEvent`, `MarchBattleResolvedEvent`, `MarchReturnedEvent`; `BattleId` у `MarchBattleResolvedEvent`. |
|
||||
| 30.10.2026 | Claude | `BattleForecast.WinChancePermille` — шанс победы (решение №11). |
|
||||
| 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`. |
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Решения 04.11: кристаллы грабятся только нападающим с технологией «Грабёж» (`IResourceProtection.Lootable(type, attackerHasPlunder)`; с ней — не больше `plunderGemsShare` запаса, ТЕСТ 10%, точные ограничения — позже). Защита остальных ресурсов без изменений. Из `EconomyConfig` убран возврат при сносе. Офлайн: добыча здания, достроенного в офлайне, — с момента завершения; уже посчитанное время второй раз не начисляется (тесты в `ProductionTests`). |
|
||||
| 01.11.2026 | Claude | `EconomyConfig`: разделы «Стройка» (время, строители, ускорение, возврат при сносе), «Лимит зданий по уровню Ратуши», «Защита ресурсов от грабежа» — всё ТЕСТ. Защита: без Складов 100 каждого ресурса, +200 за уровень Склада; еда 100, +300 за уровень Амбара; кристаллы не грабятся. Контракт `IResourceProtection` (`App/ResourceProtection`), в окне Склада и Амбара — «спрятано / можно унести». |
|
||||
| 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` «Люди» на ячейках панели. |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Разведка
|
||||
|
||||
Туман войны, источники обзора, чужие армии и последний контакт. Этап М1 по ТЗ разведки от 31.10.2026, решение №46: отдельный модуль.
|
||||
|
||||
- **Сборка:** `Bogatyri.Intelligence` (без Unity: тот же код будет считать видимость на сервере).
|
||||
- **Папка:** `Assets/Scripts/Modules/Intelligence`.
|
||||
- **Зависит от:** только ядра. Карту и армии читает у Мира через контракт `IWorldMap`, отряды — их часть. Сам Мир о Разведке не знает: прямых ссылок между World и Intelligence нет.
|
||||
- **Отвечает:** Саша (решение №46).
|
||||
|
||||
## Что умеет М1
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Туман | Три состояния клетки: **не исследовано**, **исследовано** (было в обзоре), **видно сейчас**. Клетка = клетка карты (1000 милли-единиц). |
|
||||
| Источники обзора | Своё поселение — 5 клеток, своя армия в походе — 1 клетка. Башни и герой-разведчик — позже (числа в правилах уже есть). |
|
||||
| «Обзор» I/II/III | +10 / +20 / +30% к радиусам. Уровни **не складываются** — действует текущий. Пока нет дерева технологий, уровень для теста задаётся в таблице. |
|
||||
| Чужие армии | Видны, только пока они в обзоре. Примерная численность ±20%, владелец — после опознания. |
|
||||
| Событие наблюдения | Армия вошла в обзор. На событие — **один** бросок численности и **один** бросок опознания (50%). Повторный расчёт, загрузка или нажатие не перебрасывают. Вышла и вошла снова — новое событие. |
|
||||
| Последний контакт | Армия вышла из обзора — остаётся бледная метка с последними данными. Данные больше не обновляются. Метка живёт 10 **реальных** минут. |
|
||||
| Офлайн | Наблюдение досчитывается шагами по 10 реальных секунд, не больше 48 часов. Армия, прошедшая через обзор, пока игра была закрыта, тоже будет замечена — события в порядке времени. |
|
||||
|
||||
Примерная численность: окно шириной 40% от настоящей численности, сдвинутое случайно так, чтобы настоящая численность всегда была внутри. Пример: 50 воинов → «~42–62».
|
||||
|
||||
## Контракт `IIntelligence`
|
||||
|
||||
| Член | Что делает |
|
||||
| --- | --- |
|
||||
| `ObservationLevel` | Уровень «Обзора» 0..3. |
|
||||
| `GetVisibility(x, y)` | `Unexplored` / `Explored` / `Observed` для точки карты — по последнему расчёту. |
|
||||
| `Contacts` | Известные чужие армии (`KnownContact`): где, куда идёт, `~ApproxMin–ApproxMax`, опознан ли и чей (`OwnerId` — только после опознания), видна ли сейчас, когда видели. |
|
||||
| `ObserveUntil(nowMs)` | Досчитать наблюдение до момента. Зовёт `App/GameModules` по `WorldAdvancingEvent`. |
|
||||
|
||||
## Что берёт у Мира: `IWorldMap`
|
||||
|
||||
Реализует `MarchManager` (модуль Мира).
|
||||
|
||||
| Член | Что это |
|
||||
| --- | --- |
|
||||
| `HalfWidth`, `HalfHeight` | Границы карты от центра. |
|
||||
| `TryGetSettlement(ownerId, …)` | Где поселение игрока. |
|
||||
| `GetArmies(timeMs, …)` | Все армии на карте в момент `timeMs` (`ArmyOnMap`: id, владелец, где, куда, сколько воинов). Позиция считается по времени похода, поэтому можно спросить и о прошедших минутах. |
|
||||
|
||||
## События
|
||||
|
||||
**Публикует** (у всех — `EventId` = «армия#номер наблюдения» и `TimeMs` по часам игры):
|
||||
- `ArmySpottedEvent` — чужая армия вошла в обзор, с примерной численностью;
|
||||
- `ArmyIdentifiedEvent` — владелец опознан;
|
||||
- `ArmyLostContactEvent` — армия вышла из обзора.
|
||||
|
||||
**Слушает:** ничего напрямую. `WorldAdvancingEvent` (публикует Мир перед каждым расчётом) связан с `ObserveUntil` в `App/GameModules`.
|
||||
|
||||
## Патрули-заглушки (временно)
|
||||
|
||||
Решение команды 31.10.2026: для проверки М1 соседние поселения иногда отправляют тестовые отряды к другим городам и лагерям. **Это временная тестовая механика, а не ИИ соседей.**
|
||||
|
||||
- Патрули — обычные походы Мира (`MarchData.patrol`): Разведка видит их через тот же `IWorldMap`, что и настоящие армии. Когда появится PvP, их выключают в таблице — Разведка не меняется.
|
||||
- Боя, добычи и разбития лагеря нет. Армия игрока, окна итогов и события похода их не касаются.
|
||||
- Выходят из ближайших к игроку соседних городов и идут к ближайшим к игроку целям — чтобы проходили через обзор.
|
||||
- Выпускаются по расписанию из seed карты: тот же расчёт после офлайна даёт те же патрули. Сохраняются в секции `world`.
|
||||
- Таблица `MarchConfig`: `patrolsEnabled` (вкл/выкл), `patrolMaxActive` 3, `patrolIntervalMinutes` 10 реальных минут, `patrolSoldiersMin/Max` 10–40.
|
||||
|
||||
## Карта мира
|
||||
|
||||
`WorldMap/IntelligenceOverlay.cs` рисует то, что вернула Разведка:
|
||||
- туман: не исследовано — тёмное, исследовано — притушено, в обзоре — чисто;
|
||||
- чужая армия в обзоре — красный ромб, подпись «чужая армия / ~42–62 воинов» или имя владельца, если опознан;
|
||||
- последний контакт — бледный ромб «контакт N мин назад».
|
||||
|
||||
Свои походы рисуются как раньше (`MarchView`). Чужие походы `WorldMapController` сам не рисует — только через Разведку.
|
||||
|
||||
**Что видно вне обзора** (решения по 8 вопросам Саши от 31.10, п. 1 — «Объекты видны, туман — для армий»):
|
||||
- города, замок, лагеря и ресурсные точки видны всегда — как ориентиры, туман затемняет только землю;
|
||||
- закрытые сведения — только пока объект в зоне обзора: гарнизон (на карте и в окне атаки), запас и срок ресурсной точки. Вне обзора — «неизвестно: вне обзора»;
|
||||
- поэтому вне обзора нет и прогноза: ни шанса победы, ни «сколько унесут». Напасть или пойти на сбор можно;
|
||||
- то, что игрок знает сам, видно всегда: лагерь разбит им и когда появится, на точке идёт его сбор;
|
||||
- `IntelligenceConfig.hideUnexploredObjects` (выключено) закрывает неисследованное вместе с объектами — полное скрытие для М1 не требуется, переключатель оставлен (тап по скрытому объекту при нём работает).
|
||||
|
||||
## Таблица `IntelligenceConfig`
|
||||
|
||||
`Assets/Resources/IntelligenceConfig.asset`, числа — ТЕСТ: `settlementRadius` 5, `armyRadius` 1 (клеток), `observationPercent` 10/20/30, `testObservationLevel` (только для теста), `lastContactMinutes` 10, `approxPercent` 20, `identifyPercent` 50, `sampleStepSeconds` 10, `hideUnexploredObjects`.
|
||||
|
||||
## Сохранение
|
||||
|
||||
Секция `"intelligence"`, простые строки (модуль без Unity):
|
||||
- `tick=<мс>` — до какого момента посчитано наблюдение;
|
||||
- `obzor=<уровень>`;
|
||||
- `grid=<ширина>x<высота>:<hex>` — исследованные клетки, по биту на клетку;
|
||||
- `contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели>|<номер наблюдения>`.
|
||||
|
||||
Сброс (`ResetState`): туман закрыт, контактов нет, «Обзор» 0.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Файл | Что это |
|
||||
| --- | --- |
|
||||
| `IntelligenceService.cs` | `IntelligenceRules` (числа) и `IntelligenceService` (`IIntelligence`, `ISaveParticipant`). |
|
||||
| `Bogatyri.Intelligence.asmdef` | Сборка: только ядро, без Unity. |
|
||||
|
||||
Вне модуля: `App/IntelligenceConfig.cs` (таблица), `WorldMap/IntelligenceOverlay.cs` (рисование), `World/Simulation/WorldSimulation.Patrols.cs` (патрули и позиция армии в момент времени — часть Мира).
|
||||
|
||||
## Тесты
|
||||
|
||||
`ModuleTests`: вход и выход из обзора, последний контакт и его устаревание, свои армии как источники, «Обзор» заменяет уровень, туман «исследовано» остаётся, офлайн-проход через обзор, воспроизводимые броски и ~50% опознаний, новое событие при повторном входе, сохранение и загрузка без повторного обнаружения; патрули без боёв и событий, выключение, воспроизводимость и сохранение, позиция по времени, патрули замечаются Разведкой.
|
||||
|
||||
## Решения и план
|
||||
|
||||
- М2–М4 по ТЗ: башни, герой-разведчик, разведка поселений, отчёты — следующими этапами.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 1: объекты видны всегда, а гарнизоны, запас и срок точек, прогноз боя и сбора — только в зоне обзора (`WorldMapController`). |
|
||||
| 31.10.2026 | Claude | Исправлено: после перезапуска или перемотки обратный путь войска не открывал туман (до расчёта боя Разведка считала, что войско стоит у цели). Мир досчитывается по событиям (`WorldSimulation.AdvanceInSteps`), Разведка — до каждого из них. Туман темнее. |
|
||||
| 31.10.2026 | Claude | Модуль создан: этап М1 — туман, источники обзора, «Обзор», чужие армии, примерная численность, опознание, последний контакт, офлайн-наблюдение, сохранение. Патрули-заглушки соседей для проверки (выключаются в `MarchConfig`). |
|
||||
@@ -20,7 +20,7 @@
|
||||
- **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру.
|
||||
- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены.
|
||||
- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали.
|
||||
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
|
||||
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, ничего не считается, а метка последнего расчёта назад **не** уходит: когда часы снова дойдут до уже посчитанного, второй раз оно не начислится (решения 04.11). В начале каждого шага и в конце `Tick` модуль публикует `GameTimeSimulatedEvent` — до какого момента дошёл расчёт; по нему стройка, закончившаяся офлайн, вступает в силу ровно в свой момент (`App/ConstructionClock`).
|
||||
- **Еда списывается целыми единицами,** доли копятся в модуле: склад хранит `float`, и частые мелкие списания давали бы ошибку округления.
|
||||
|
||||
## Контракт `IPopulation`
|
||||
@@ -31,7 +31,7 @@
|
||||
| `Civilians`, `Workers`, `Idle` | Жители, из них на работе и остальные. |
|
||||
| `Workforce`, `FreeWorkers` | Сколько могут работать; сколько из них без работы. |
|
||||
| `GetWorkers(job)`, `SetWorkers(job, n)` | Рабочие профессии; поставить n (в пределах мест и свободных). |
|
||||
| `Soldiers` | Воины — из модуля Армии. |
|
||||
| `Soldiers` | Воины — из модуля Армии: в городе, в походах и раненые (решения 31.10, п. 7: раненые — тоже люди). |
|
||||
| `HousingCap` | Предел населения. |
|
||||
| `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. |
|
||||
| `IsStarving` | Еды не хватило на последний расчёт. |
|
||||
@@ -88,7 +88,7 @@
|
||||
- упёрлись в предел — прирост стоит, Ратуша выше — идёт; предел не превышается;
|
||||
- офлайн ограничен 48 часами;
|
||||
- частые расчёты дают то же, что один большой;
|
||||
- часы назад — ничего не ломается;
|
||||
- часы назад — ничего не ломается, уже посчитанное время не начисляется второй раз;
|
||||
- перемотка идёт через те же правила;
|
||||
- сохранение;
|
||||
- стартовый набор — один раз;
|
||||
@@ -106,12 +106,15 @@
|
||||
- п. 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, ждёт решения):** нормы еды для четырёх групп (гражданские и рабочие, новобранцы, солдаты, раненые); переход экономики на игровое время (1 игровой час = 5 реальных минут) и единицы офлайна (48 ч) и истории голода; кто считает предел жилья и рабочие места (по регламенту — Поселение); недоступен ли резерв и для работы.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude (Валдемар) | Решения 04.11 (офлайн по временным меткам): событие `GameTimeSimulatedEvent` на каждом шаге расчёта и в конце `Tick` — стройка, закончившаяся офлайн, вступает в силу в свой момент, добыча с момента завершения. Часы назад: метка последнего расчёта не уходит назад — закрыто повторное начисление. Тесты в `ProductionTests` (офлайн-стройка, повторный Tick, загрузка), обновлён тест про часы назад. Побочный эффект: после «DEV +10 часов» и перезапуска расчёт стоит, пока реальное время не догонит перемотку. |
|
||||
| 01.11.2026 | Claude (Саша) | Раненые считаются людьми: `Soldiers` = в городе + в походах + `IArmy.WoundedTotal` (решения 31.10, п. 7). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). |
|
||||
| 01.11.2026 | Claude (Саша) | Очередь обучения перенесена в Армию (решения 31.10, п. 8). Население по-прежнему отдаёт жителей (`TryRecruit`) и принимает новобранцев в воины (`CompleteTraining`) — его код не менялся, зовёт их теперь Армия. `RecruitsTrained` больше никто не вызывает. |
|
||||
| 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 (ТЕСТ). Тесты. |
|
||||
|
||||
+21
-2
@@ -1,6 +1,6 @@
|
||||
# Модульная система «Богатырей»
|
||||
|
||||
Игра собрана из десяти модулей вокруг общего **Игрового ядра** (регламент 31.10.2026; модуль Разведки — в ветке Саши `feature/intelligence-m1`, на схеме появится с ней). Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
|
||||
Игра собрана из десяти модулей вокруг общего **Игрового ядра** (регламент 31.10.2026). Модули общаются только через контракты и события ядра и не управляют внутренним кодом друг друга. Это правило проверяет сам Unity: каждый модуль — отдельная сборка (`.asmdef`), которая видит только ядро, поэтому прямое обращение к соседнему модулю не скомпилируется.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
@@ -54,7 +54,7 @@ App/GameModules — точка сборки: регистрирует модул
|
||||
| Кто | Модули |
|
||||
| --- | --- |
|
||||
| Валдемар (экономика) | Экономика, Население |
|
||||
| Саша (война и карта) | Армия, Боевая система, Глобальный мир, Круг, Чат |
|
||||
| Саша (война и карта) | Армия, Боевая система, Глобальный мир, Круг, Чат, Разведка |
|
||||
| Общее | Ядро, Поселение, Сохранения |
|
||||
|
||||
| Модуль | Сборка | Папка | Документ |
|
||||
@@ -69,6 +69,7 @@ App/GameModules — точка сборки: регистрирует модул
|
||||
| Сохранения | `Bogatyri.Save` | `Assets/Scripts/Modules/Save` | [Save.md](Save.md) |
|
||||
| Чат | `Bogatyri.Chat` (без Unity) | `Assets/Scripts/Modules/Chat` | [Chat.md](Chat.md) |
|
||||
| Круг | `Bogatyri.Circle` (без Unity) | `Assets/Scripts/Modules/Circle` | [Circle.md](Circle.md) |
|
||||
| Разведка | `Bogatyri.Intelligence` (без Unity) | `Assets/Scripts/Modules/Intelligence` | [Intelligence.md](Intelligence.md) |
|
||||
|
||||
## Правила
|
||||
|
||||
@@ -104,8 +105,26 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude (Валдемар) | Решения Димы 04.11: снос зданий убран (только бесплатный перенос), кристаллы грабятся только с технологией «Грабёж», офлайн по временным меткам — добыча с момента завершения стройки, защита от повторного начисления (метка расчёта не уходит назад). +5 тестов. Обновлены Settlement, Economy, Core. |
|
||||
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11: потери сильной стороны до 2% при 10:1, 5 временных мест госпиталя, цена лечения 1 / 1 / 2 / 3 золота. Обновлены Battle, Army. |
|
||||
| 01.11.2026 | Claude (Валдемар) | Поселение: стройка и улучшение по времени, 2 строителя, ускорение; лимит зданий по Ратуше; перенос и снос; защита ресурсов от грабежа (`IResourceProtection`). Терем богатыря и герой в городе. Казармы: найм карточками в ряд. Числа — ТЕСТ. Обновлены Settlement, Economy, Core. |
|
||||
| 01.11.2026 | Claude | Здание «Госпиталь» (решения 31.10, п. 7). Обновлены Army, Core, Settlement. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и госпиталь (логика; здание — следующий шаг). Обновлены Battle, Army, Population, World, Core, Settlement. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 8: очередь обучения — у Армии. Обновлены Army, Core, Population. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка». Обновлены World, Core, Intelligence. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 6: 24 ресурсные точки. Обновлён World. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 5: разгром дальше 5:1 (92% / 95% / до 99%). Обновлён Battle. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 4: средняя скорость смешанного войска. Обновлён World. |
|
||||
| 01.11.2026 | Claude | Решения по 8 вопросам Саши (31.10), п. 1: гарнизоны и запасы точек видны только в зоне обзора. Обновлён Intelligence. |
|
||||
| 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. |
|
||||
| 31.10.2026 | Claude | Модуль Разведки, этап М1 ([Intelligence.md](Intelligence.md)): туман, обзор, чужие армии, последний контакт. Патрули-заглушки соседей для проверки. Обновлены World, Core, Save. |
|
||||
| 31.10.2026 | Claude | Панель армий на карте мира (регламент 31.10). Обновлён World. |
|
||||
| 31.10.2026 | Claude | Ресурсные точки на карте мира по регламенту 31.10 (наша часть по решению №46: состояние точки и сбор; зачисление — Экономика через `IEconomy`). Обновлены World, Save, Core. |
|
||||
| 31.10.2026 | Claude | Скорости маршей по типам и слоты похода по регламенту 31.10 (таблица `MarchConfig`). Обновлены World, Army, Core. |
|
||||
| 31.10.2026 | Claude | Кривая потерь боя по регламенту 31.10 (решение №14). Обновлён Battle. |
|
||||
| 30.10.2026 | Claude | Мир по документу 02: события строго по времени после офлайна, время и номер боя в событиях похода, единицы у таймеров; тесты «Готово, когда» (порядок, двойная доставка, таймеры, 0/1 солдат). Обновлены World, Core. |
|
||||
| 30.10.2026 | Claude | Бой по решениям №11–13 комплекта 29.10: шанс победы, сила без промежуточного округления, округление потерь шансом; голод в офлайн-бою по истории Населения. Обновлены Battle, World, 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. |
|
||||
|
||||
@@ -34,7 +34,8 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
| --- | --- |
|
||||
| `economy` | Экономика |
|
||||
| `settlement` | Поселение |
|
||||
| `world` | Глобальный мир (походы и разбитые лагеря) |
|
||||
| `world` | Глобальный мир (походы, разбитые лагеря, ресурсные точки, патрули-заглушки) |
|
||||
| `intelligence` | Разведка (простые строки: `tick=…`, `obzor=…`, `grid=…`, `contact=…`) |
|
||||
| `circle` | Круг (простые строки, не JSON — модуль без Unity) |
|
||||
| `army` | Армия (простые строки: `troops=…`, `march=…`) |
|
||||
| `population` | Население (простые строки: `civilians=…`, `jobs=…`, `lastTick=…` и др.) |
|
||||
@@ -106,6 +107,8 @@ SaveRegistry.Register(myModule); // при запуске модуля
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 31.10.2026 | Claude | Секция `intelligence` (Разведка М1); в `world` — состояние патрулей-заглушек `patrols`. |
|
||||
| 31.10.2026 | Claude | Секция `world`: ресурсные точки (`points`: запасы, сроки, кто собирает). Старые сохранения без точек читаются — точки появятся при первом расчёте мира. |
|
||||
| 27.10.2026 | Claude | Секция `world`: добавлен список разбитых лагерей `camps`. |
|
||||
| 27.10.2026 | Claude | Новая секция `production`; в секции `economy` — список `capacities` (лимиты склада). |
|
||||
| 26.10.2026 | Claude | Новая секция `population` (модуль Населения). `GameBootstrap` публикует `NewGameStartedEvent`, если файла нет. |
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
4. Нижняя панель: «Поставить» (место свободно и хватает ресурсов), «Другое», «Отмена».
|
||||
5. После постройки режим остаётся; «Здания» сверху — строить дальше, «Выйти» — сетка прячется.
|
||||
|
||||
## Стройка по времени, лимиты, перенос (01.11.2026, решения 04.11; числа — ТЕСТ)
|
||||
|
||||
- **Время реальное** (решение 04.11): 60 с стройки — 60 реальных секунд, «игровой час» к стройке не относится. Развитие долгосрочное: позже — тиры T1/T2/T3 и внутри них уровни с растущим временем (шкалу согласуют отдельно).
|
||||
- **Стройка и улучшение идут по времени** (`BuildTimer`). Постройка — `buildSeconds` (60 с), улучшение до уровня N — `upgradeSecondsToLevel` (60 с … 2 ч), Ратуша ×2. Время — часы игры (`GameClock.UnixNowMs`): идёт и в офлайне, и при перемотке DEV.
|
||||
- **Пока здание строится,** оно полупрозрачное и не работает: его скрипты выключены, `App/CitySync` его не считает (нет мест, жилья, лимитов; Казармы не учат, Терем без героя). При улучшении здание работает на старом уровне, новый — когда время выйдет.
|
||||
- **Строители:** одновременно не больше `builders` (2) строек и улучшений, Ратуша тоже занимает строителя.
|
||||
- **Ускорение:** 1 кристалл за `secondsPerGem` (30) секунд оставшегося времени — кнопка в окне здания.
|
||||
- **Лимит зданий по уровню Ратуши** (`EconomyConfig.buildingLimits`): в списке стройки «построено 2 / 3», сверх лимита — «Лимит: больше на Ратуше N ур.». Здания не из таблицы — без лимита. В окне Ратуши — каких зданий станет больше на следующем уровне.
|
||||
- **Перенос:** кнопка «Перенести» в окне здания — режим стройки с призраком на месте здания, «Сюда» — здание переезжает бесплатно (`BuildModeController.StartMove`, `BuildGrid.IgnoredBuilding`). Стройка или улучшение продолжаются.
|
||||
- **Сноса нет** (решение 04.11): удалить здание и вернуть ресурсы нельзя. Перенос — бесплатно, в пределах поселения, уровень и текущие стройка или улучшение сохраняются. Ратушу не переносят.
|
||||
- **Офлайн по временным меткам** (решение 04.11): в сохранении хранится момент конца стройки. После входа стройка вступает в силу в тот момент офлайн-расчёта добычи, когда время дошло до её конца (`App/ConstructionClock`, событие `GameTimeSimulatedEvent` от Населения, `BuildTimer.MayComplete`). Добыча нового здания или уровня идёт с момента завершения (точность — шаг расчёта 10 минут, никогда не раньше конца стройки).
|
||||
- **Проверки:** таймер из файла принимается, только если он осмыслен (постройка — у здания 1 уровня, улучшение — до уровня выше текущего); завершение только повышает уровень, повторное ничего не даёт; перенос не тратит и не возвращает ресурсы.
|
||||
|
||||
## Контракт `ISettlement`
|
||||
|
||||
| Свойство | Что значит |
|
||||
@@ -48,7 +61,7 @@
|
||||
|
||||
## Сохранение
|
||||
|
||||
Секция `"settlement"`: `{ "townHallLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level" } ] }`.
|
||||
Секция `"settlement"`: `{ "townHallLevel", "townHallTimerStartMs", "townHallTimerEndMs", "townHallTimerTargetLevel", "placedBuildings": [ { "buildingName", "posX", "posY", "level", "timerKind", "timerStartMs", "timerEndMs", "timerTargetLevel" } ] }`. Поля таймера (01.11) — стройка или улучшение, которое ещё идёт (`timerKind`: 0 — нет, 1 — постройка, 2 — улучшение). В старых сохранениях их нет — значит, ничего не строится.
|
||||
|
||||
Сброс (`ResetState`): Ратуша 1 уровня, постройки убраны, участки освобождены, память модуля очищена. Иначе при следующем входе в город вернулись бы старые здания.
|
||||
|
||||
@@ -82,6 +95,10 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Решения 04.11: снос убран, остался бесплатный перенос; офлайн по временным меткам — стройка, закончившаяся офлайн, вступает в силу в момент завершения внутри расчёта добычи (`ConstructionClock`, `GameTimeSimulatedEvent`, `BuildTimer.MayComplete`); проверки таймеров из сохранения; уровень при завершении только растёт. |
|
||||
| 01.11.2026 | Claude | Стройка и улучшение по времени (`BuildTimer`), 2 строителя, ускорение за кристаллы, офлайн; лимит зданий по уровню Ратуши; перенос и снос зданий из окна. Сохранение: поля таймера у зданий и Ратуши (старые сохранения читаются). Терем богатыря (`HeroCastle`) и гуляющий герой. Числа — ТЕСТ, `EconomyConfig`. |
|
||||
| 01.11.2026 | Claude (Саша) | Новое здание «Госпиталь» в списке стройки (`Meta/Build/HospitalBootstrap.cs`, как Казармы): уровни I–V сохраняются как у остальных (`ILeveledBuilding`), тап — окно госпиталя. Места — в `HospitalConfig`. Арта нет: копия обычного здания светло-голубого цвета. |
|
||||
| 01.11.2026 | Claude (Саша) | Для госпиталя (решения 31.10, п. 7) в ядре есть контракт `IHospital.Beds` — его будет регистрировать здание госпиталя (уровни I–V, 20–60 мест). Здания пока нет. |
|
||||
| 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`), из Поселения убрано. |
|
||||
|
||||
+78
-3
@@ -14,6 +14,11 @@
|
||||
| --- | --- |
|
||||
| `ActiveMarches` | Сколько походов в пути. |
|
||||
| `TryStartMarch(targetId, soldiers, out error)` | Отправить войско на цель. |
|
||||
| `TryStartScout(targetId, soldiers, out error)` | Поход «Разведка»: дойти до цели и вернуться — без боя, сбора и потерь. |
|
||||
|
||||
`MarchManager` реализует и `IWorldMap` — для Разведки: границы карты, где поселение, где все армии в момент времени (`WorldSimulation.TryGetPositionAt`). `ActiveMarches` считает только походы игрока.
|
||||
|
||||
**Патрули-заглушки** (Разведка М1, временно): соседи иногда отправляют отряды к городам и лагерям и обратно. Без боя и добычи, Армия о них не знает. Включаются в `MarchConfig` (`patrolsEnabled` и др.), см. [Intelligence.md](Intelligence.md).
|
||||
|
||||
Получить: `GameServices.Get<IWorld>()`. Интерфейсу карты (`WorldMapController`) можно обращаться к `MarchManager.Instance` и его C#-событиям: там полные данные похода.
|
||||
|
||||
@@ -21,10 +26,16 @@
|
||||
|
||||
**Публикует:**
|
||||
|
||||
- `MarchStartedEvent` — войско выступило;
|
||||
- `MarchBattleResolvedEvent` — бой у цели прошёл;
|
||||
У всех событий похода — номер похода и `TimeMs`: когда это случилось по часам игры, а не когда игра это заметила (после офлайна это разные моменты; документ 02).
|
||||
|
||||
- `MarchStartedEvent` — войско выступило (`TimeMs` = выступление);
|
||||
- `MarchBattleResolvedEvent` — бой у цели прошёл (`TimeMs` = приход, `BattleId` = номер боя);
|
||||
- `MarchReturnedEvent` — войско вернулось, солдаты и добыча зачислены. Поля: `MarchId`, `Victory`, `Survivors`, `Sent`, `TargetEmpty` (лагерь уже был разбит, боя не было), `TargetName`, `LootText`.
|
||||
|
||||
- `WorldAdvancingEvent` — мир сейчас продвинется до `ToMs` (перед каждым расчётом): Разведка успевает досчитать наблюдение, пока вернувшиеся походы ещё на карте.
|
||||
|
||||
У патрулей-заглушек событий нет.
|
||||
|
||||
**Слушает:** ничего.
|
||||
|
||||
По этим событиям `App/GameModules`:
|
||||
@@ -49,6 +60,7 @@
|
||||
| `MarchManager.cs` | Ведёт походы, реализует `IWorld` и `ISaveParticipant`. Создаётся сам при запуске игры. |
|
||||
| `Simulation/WorldMapLayout.cs` | Карта: замок, город игрока, 20 соседей и 12 лагерей из seed. |
|
||||
| `Simulation/WorldSimulation.cs` | Походы: старт, бой по прибытии, возвращение. Бой получает через `IBattleSystem`. |
|
||||
| `Simulation/WorldSimulation.Patrols.cs` | Патрули-заглушки соседей (`PatrolRules`, `PatrolState`) и позиция армии в момент времени (`TryGetPositionAt`). |
|
||||
|
||||
**Бой с типами войск:**
|
||||
- у похода есть состав по типам (`MarchData.units`, берётся у Армии при выступлении) и выжившие по типам (`survivorUnits`);
|
||||
@@ -75,10 +87,62 @@
|
||||
|
||||
**Номер боя:** при выступлении поход получает `battleSeed` (из номера похода, времени выступления и цели) и хранит его в сохранении. Тот же поход всегда даёт тот же бой. После боя в походе запоминаются силы сторон и выпавшая случайность (`attackerPower`, `defenderPower`, `attackerLuck`, `defenderLuck`). Окно итога боя на карте показывает «Сила 356 : 160, удача −3,1% / +3,2%» (твоя / врага); текст окна ужимается, чтобы не уходить под кнопку OK. Окно атаки показывает прогноз риска и диапазоны силы и потерь.
|
||||
|
||||
**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Если бой прошёл, пока игра была закрыта, берётся голод на момент входа (истории голода нет). Окно атаки предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Если голод начался или кончился, пока окно атаки открыто, предупреждение и прогноз обновляются сами. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`.
|
||||
**Голод в бою:** перед боем мир спрашивает состояние войска нападающего (`WorldSimulation.AttackerConditions`). `MarchManager` отвечает по модулю Населения: город голодает — войско бьётся на 50% силы. Мир сам голод не считает. Голод берётся на момент прихода войска: `IPopulation.WasStarvingAt(время боя)`. Перед боем, прошедшим пока игра была закрыта, `MarchManager` просит Население досчитать время (`IPopulation.Tick`) — офлайн обрабатывается в причинно-следственном порядке (документ 02). Окно атаки показывает шанс победы (решение №11) и предупреждает: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод. Если голод начался или кончился, пока окно атаки открыто, предупреждение и прогноз обновляются сами. Окно итога боя пишет «голод: сила 50%». В походе запоминается `attackerConditions` и `attackerConditionPercent`.
|
||||
|
||||
**Время** походов — по `GameClock`: кнопки «+1 час» / «+10 часов» в отладочной панели проматывают и походы.
|
||||
|
||||
**Ресурсные точки (регламент 31.10, раздел 7; решение №46)**
|
||||
|
||||
Состояние точки ведёт Мир: запас, появление, истощение, конец экспедиции. Зачисление добычи и складские лимиты — Экономика: груз приходит как добыча похода (`MarchData.loot`) и зачисляется через `IEconomy` по возвращении, как трофеи боя. Числа — таблица `Assets/Resources/WorldResourceConfig.asset` (ТЕСТ).
|
||||
|
||||
| Ресурс | Запас: малая / средняя / крупная | Добыча воина за игровой час | Груз на воина |
|
||||
| --- | --- | --- | --- |
|
||||
| Дерево (Лес) | 200 / 600 / 1500 | 2 | 12 |
|
||||
| Камень (Каменоломня) | 150 / 450 / 1000 | 1,5 | 10 |
|
||||
| Металл (Рудник) | 80 / 250 / 600 | 1 | 8 |
|
||||
| Золото (Золотая жила) | 30 / 100 / 250 | 0,5 | 5 |
|
||||
|
||||
- **Срок жизни** — 12 / 24 / 48 реальных часов с момента появления. 1 игровой час = 5 реальных минут.
|
||||
- **Сбор:** дошли до точки → войско стоит и собирает (`MarchState.Gathering`). Сбор кончается, когда груз полон, запас истощён или точка исчезла — что раньше; затем физический возврат с грузом. Всё известно в момент прихода, поэтому работает и при закрытой игре. Пример: 10 пехотинцев на Лес — 120 дерева за 30 реальных минут.
|
||||
- **Осадные машины груза не несут** (регламент); добывают наравне с остальными.
|
||||
- **Одна точка — одно войско.** Пока к точке идёт или на ней стоит войско, отправить другое нельзя.
|
||||
- **Точка исчезла до прихода** — возврат без добычи (окно «ТОЧКИ НЕТ»). Исчезла во время сбора — уносят, что успели.
|
||||
- **Истощённая или исчезнувшая точка** заменяется новой того же ресурса в другом месте (место и размер — из seed и номера появления, одинаково на сервере). Убирается с карты, когда к ней уже никто не идёт.
|
||||
- **На карте** — ромб цвета ресурса с подписью «запас, сколько осталось жить» или «идёт сбор». Окно отправки показывает запас, добычу и груз на воина, путь и прогноз «унесут N за T, весь поход ~X».
|
||||
- **Сколько точек** (решения по 8 вопросам Саши от 31.10, п. 6, ТЕСТ тестового участка): **24** — лес 8, камень 7, металл 5, золото 4; по размерам 12 малых, 8 средних, 4 крупных. Раскладка по ресурсам (документ её не задаёт, задана так): лес 4/3/1, камень 4/2/1, металл 2/2/1, золото 2/1/1 — `WorldResourceConfig` (`woodCount`, `stoneCount`, `metalCount`, `goldCount`). Исчезла точка — появляется точка того же ресурса и размера в другом месте, раскладка не плывёт. Старое сохранение дополняется до таблицы при загрузке.
|
||||
- На одной точке собирает только одна экспедиция: пока к точке идёт или на ней стоит войско, другое туда не отправить.
|
||||
- Точек еды нет (еда — с ферм). Синие кристаллы — отдельная механика, не в этой сетке.
|
||||
- **Открыто:** предел добычи на точку («скорость с пределом на точку»).
|
||||
- **Позже:** нападение на занятую точку (бой в поле, победитель забирает до 50% накопленного) — когда появятся другие игроки; досрочный отзыв — технология «Связь Волхвов»; переполнение склада при возвращении (5 минут на разгрузку) — вместе с Экономикой.
|
||||
|
||||
**Панель армий** (регламент 31.10, раздел 7: «нужна всем игрокам»): кнопка «Армии» на карте мира, под «Рейтингом» — окно `WorldMap/ArmiesWindow.cs`. Вверху — дома свободно, гарнизон, походов N из M. По каждому войску игрока: куда и что делает (идёт в бой / идёт на сбор / собирает / возвращается), состав по типам, груз, сколько до цели или до конца сбора и когда будет дома. Таймеры обновляются сами, пока окно открыто. До отправки окно атаки показывает длительность всего задания: для боя «туда и обратно ~X», для сбора «весь поход ~X».
|
||||
|
||||
**Поход «Разведка»** (решения по 8 вопросам Саши от 31.10, п. 2): кнопка «Разведка» в окне атаки, между «Отмена» и «Отправить».
|
||||
- Цель — любая, кроме своего города: замок, сосед, лагерь (и разбитый), ресурсная точка (и занятая).
|
||||
- Занимает обычный слот похода; воинов забирает Армия, как для боя.
|
||||
- У цели боя, сбора и потерь нет: войско поворачивает домой тем же составом (`WorldEventType.ScoutArrived`) и идёт обратно по времени, без телепортации. Цели уже нет (точка исчезла) — дошли до места и вернулись (`targetEmpty`).
|
||||
- Лагерь не разбивается, точка не занимается и не мешает чужому сбору.
|
||||
- Что войско увидит по пути и у цели — показывает Разведка (обзор армии 1 клетка): гарнизон цели, чужие армии. Отчёты и шпионаж — М2–М4.
|
||||
- Окно «ВОЙСКО ВЕРНУЛОСЬ»: «Разведка «…»: цель разведана». В событиях — `MarchStartedEvent.IsScout`, `MarchReturnedEvent.IsScout`.
|
||||
|
||||
**Армии в пути проходят друг через друга** (решения по 8 вопросам Саши от 31.10, п. 3): встреча маршрутов, даже в одной точке в один момент, — не столкновение, не остановка и не перехват. Бой — только у выбранной цели. Так же и с патрулями-заглушками. Команды «Перехватить» нет. Тест `World_ArmiesPassThroughEachOther_NoBattleOnTheWay`.
|
||||
|
||||
**Порядок событий** (документ 02: офлайн — в причинно-следственном порядке): `WorldSimulation.Advance` каждый раз берёт самое раннее событие — приход к цели (бой или начало сбора), конец сбора, исчезновение ресурсной точки или возвращение. Бой назначает время возвращения, поэтому за один вызов поход может и сразиться, и вернуться. При равном времени — бой раньше возвращения, затем по номеру похода. Раньше за один вызов сначала шли все бои, потом все возвращения — войско, вернувшееся раньше чужого боя, попадало в события после него. Повторный вызов с тем же временем ничего не создаёт заново.
|
||||
|
||||
**Таймеры и единицы** (документ 02: у каждого параметра — единицы):
|
||||
|
||||
| Таймер | Значение | Единицы | Статус |
|
||||
| --- | --- | --- | --- |
|
||||
| Скорость войска (`MarchConfig`, регламент 31.10) | пехота 8, лучники 9, кавалерия 12, осадные 4 клетки | за 1 реальный час | ТЕСТ |
|
||||
| Время в пути | пехота: ближняя цель ~53 мин, замок ~65 мин, дальняя ~3 ч 12 мин; осадные — вдвое дольше пехоты | реальных минут/часов | ТЕСТ |
|
||||
| Появление разбитого лагеря (`CampRules.RespawnMs`) | 30 | реальных минут после боя | ТЕСТ (план 27.10: «30 реальных минут») |
|
||||
|
||||
Все таймеры мира — в миллисекундах часов `GameClock` (реальное время + перемотка DEV). Регламент 31.10: 1 игровой час = 5 реальных минут, но **физический марш идёт в реальном времени** — поэтому таймеры мира остаются реальными.
|
||||
|
||||
**Скорость марша** (регламент 31.10, раздел 7): у каждого типа своя скорость, таблица `Assets/Resources/MarchConfig.asset` (клеток карты за реальный час; 1 клетка = 1000 единиц координат). Смешанное войско идёт со **средней скоростью по численности** (решения по 8 вопросам Саши от 31.10, п. 4): `V = Σ(Nᵢ × Vᵢ × Mᵢ) / ΣNᵢ`, где `Mᵢ` — модификатор типа, а общий модификатор войска применяется один раз. Промежуточного округления нет: время пути считается точной дробью и округляется один раз в конце. Пример: 10 пехоты + 10 кавалерии = 10 клеток в час, с общим +10% — 11. Модификаторы в `MarchConfig`: `typeSpeedBonusPercent` (по типам) и `armySpeedBonusPercent` (всё войско), сейчас 0 — сюда придут бонусы героев и технологий. Совместные рейды нескольких армий (средняя скорость + штраф координации 0 / −5 / −10 / −15%) — когда появятся рейды. Время прихода пересчитывается, когда Армия забрала воинов (`WorldSimulation.AssignUnits`); обратно идут выжившие — со скоростью своего состава (погибли осадные — войско возвращается быстрее). Окно атаки показывает путь по составу при выбранном числе воинов. Бонусы героя (+15%) и технологии (+20%) — когда появятся герои и технологии.
|
||||
|
||||
**Слоты похода**: база 2 (`MarchConfig.baseMarchSlots`, ТЕСТ), «Военная логистика» поднимет до 7 — уровни и цены не утверждены. Слоты ведёт модуль Армии (`IArmy.MarchSlots`, `ActiveMarches`). При занятых слотах окно атаки пишет «Все слоты похода заняты», кнопка «В поход» неактивна; верхняя строка карты — «отрядов: 1 из 2».
|
||||
|
||||
В слое приложения остаются `WorldMap/WorldMapController.cs`, `MarchView.cs`, `MapCameraController.cs`, `SceneNavigator.cs`.
|
||||
|
||||
**В игре одна карта мира** — сцена WorldMap. Открывается кнопкой «БОЙ» и кнопкой «МИР» в городе (`UIPanelManager.Open("World")` ведёт в эту сцену).
|
||||
@@ -94,6 +158,17 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка» (`IWorld.TryStartScout`, `MarchData.scout`, `WorldEventType.ScoutArrived`), кнопка в окне атаки, тексты в окне армий, на отряде и в окне возвращения. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 3: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 6: 24 ресурсные точки (лес 8, камень 7, металл 5, золото 4; размеры 12 / 8 / 4), число по ресурсу и размеру — в `WorldResourceConfig`; новая точка — того же размера; старые сохранения дополняются. |
|
||||
| 01.11.2026 | Claude | Решения 31.10, п. 4: скорость смешанного войска — средняя по численности с модификаторами (вместо «по самому медленному»), без промежуточного округления. `MarchConfig`: `mixedUsesSlowest` убран, добавлены `typeSpeedBonusPercent`, `armySpeedBonusPercent`. |
|
||||
| 31.10.2026 | Claude | Для Разведки М1: `IWorldMap` (армии на карте в момент времени), `WorldAdvancingEvent`, патрули-заглушки соседей (`MarchData.patrol`, сохранение `patrols`, таблица `MarchConfig`). |
|
||||
| 31.10.2026 | Claude | Панель армий (`WorldMap/ArmiesWindow.cs`, кнопка «Армии» на карте мира): местонахождение и действие каждого войска, состав, груз, время до цели / конца сбора / возвращения. В окне атаки — длительность всего задания для боя. |
|
||||
| 31.10.2026 | Claude | Ресурсные точки (регламент 31.10, раздел 7): `ResourcePoint`, `ResourcePointRules`, `WorldSimulation.Points.cs` — появление, сбор (`MarchState.Gathering`), истощение, исчезновение, замена новой точкой; события `GatherStarted`, `GatherFinished`; сохранение в секции `world`; таблица `WorldResourceConfig`; на карте — ромбы точек, окно отправки с прогнозом сбора, подпись отряда «сбор». Тесты: появление, сбор и груз, истощение и новая точка, осадные без груза, точка исчезла до прихода и во время сбора, одна точка — одно войско, сохранение посреди сбора. |
|
||||
| 31.10.2026 | Claude | Скорости маршей по типам (регламент 31.10: 8 / 9 / 12 / 4 клетки за реальный час, таблица `MarchConfig`), войско — по самому медленному типу, обратно — по составу выживших; `WorldSimulation.TravelTimeMs`, `AssignUnits`, `MarchSpeedRules`; слоты похода (база 2) в окне атаки и верхней строке. Тесты: скорости по типам, смешанное войско, обратный путь; старые тесты переведены на часовые марши. |
|
||||
| 30.10.2026 | Claude | События строго по времени после офлайна (раньше — сначала все бои, потом все возвращения); `TimeMs` у событий похода и `WorldEvent`, `BattleId` у события боя; таблица таймеров с единицами. Тесты: порядок после офлайна, повторный вызов без дублей, границы таймеров, 0 и 1 солдат. |
|
||||
| 30.10.2026 | Claude | Голод на момент боя — по истории голода Населения (`WasStarvingAt`), перед офлайн-боем Население досчитывает время. Окно атаки: шанс победы в процентах. |
|
||||
| 28.10.2026 | Claude | Голод в момент боя ослабляет войско игрока (−50%): `WorldSimulation.AttackerConditions`, `MarchManager.PlayerConditions`; предупреждение в окне атаки, строка в итоге боя. Тест: голод в момент боя — поражение. |
|
||||
| 28.10.2026 | Claude | Номер боя у похода (`battleSeed`), силы и удача сторон в итоге; прогноз риска в окне атаки, удача в окне итога боя. Тест: тот же поход — тот же бой. |
|
||||
| 28.10.2026 | Claude | По ревью 27.10.2026: отправка с проверкой и откатом (`CancelMarch`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. |
|
||||
|
||||
Reference in New Issue
Block a user