Author SHA1 Message Date
vologdia bb3baba3b7 Исправление: атрибут RuntimeInitializeOnLoad вернулся к MigrateLegacyMarches 2026-11-05 11:54:20 +03:00
vologdia b3296c7a55 Merge remote-tracking branch 'origin/feature/intelligence-m1' into feature/troop-cards
# Conflicts:
#	Docs/Modules/README.md
2026-11-05 11:44:26 +03:00
vologdia b1bb863807 meta для ConstructionClock 2026-11-05 11:15:41 +03:00
vologdia 8b6a7869a4 Решения 04.11: снос убран (только перенос), кристаллы грабятся с технологией Грабёж, офлайн по временным меткам, защита от повторного начисления, тесты 2026-11-04 23:36:14 +03:00
AlexanderandClaude Opus 5.5 f50591d5fe Решения по отчёту от 01.11: потери сильной стороны до 2% при 10:1, 5 временных мест госпиталя, лечение 1/1/2/3 золота
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:42:07 +01:00
vologdia d30fd34810 Поселение: стройка по времени и строители, лимит зданий по Ратуше, перенос и снос, защита ресурсов; Терем богатыря; найм карточками 2026-11-02 00:01:51 +03:00
vologdia 1b7da37c7e Казармы: найм войск карточками в ряд, сильные и слабые стороны из правил боя, место под иконки 2026-11-01 23:34:08 +03:00
AlexanderandClaude Opus 5.5 af57a13f0f Здание «Госпиталь»: уровни I-V, 20-60 мест, окно с ранеными и улучшением
Места -- модулю Армии через IHospital (HospitalBeds); без госпиталя мест нет, раненые ждут.
Добавляется в список стройки кодом, как Казармы. Числа -- HospitalConfig (ТЕСТ).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:52:47 +01:00
AlexanderandClaude Opus 5.5 0214bdb268 Раненые и госпиталь (решения 31.10, п. 7): логика
Бой делит выбывших на погибших и раненых по соотношению сил стороны (70/45/30/20%), версия правил 6.
Раненые идут домой с войском; Армия кладёт их в госпиталь, лечит 4/4/6/8 игровых часов, лишние ждут места.
Места -- от IHospital (здания пока нет -- HospitalConfig.temporaryBeds), цена -- через Экономику (не утверждена).
Население считает раненых людьми. Здание госпиталя -- следующий шаг.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:32:25 +01:00
AlexanderandClaude Opus 5.5 482721a704 Очередь обучения -- у Армии (решения 31.10, п. 8); окно «Разведка вернулась» на карте
Армия: заказы, сроки по часам игры, завершение один раз (TroopsTrainedEvent), сохранение train=.
Население отдаёт жителей и принимает новобранцев через свой контракт.
GameProgress (код Вальдемара, с разрешения команды): только цена, Казармы, окна и статистика;
старая очередь из progress.json переносится один раз с тем же оставшимся временем.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:19:34 +01:00
AlexanderandClaude Opus 5.5 33db41002e Поход «Разведка» (решения 31.10, п. 2): до цели и обратно без боя, сбора и потерь, обычный слот
IWorld.TryStartScout, MarchData.scout, WorldEventType.ScoutArrived; кнопка «Разведка» в окне атаки;
разведка не разбивает лагерь и не занимает точку.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:11:55 +01:00
AlexanderandClaude Opus 5.5 24268f3cfb Армии в пути проходят друг через друга (решения 31.10, п. 3): правило в документе, тест на одну точку в один момент
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:09:26 +01:00
AlexanderandClaude Opus 5.5 3a86eadc0b 24 ресурсные точки по решениям 31.10 (п. 6): лес 8, камень 7, металл 5, золото 4; размеры 12/8/4
Число по ресурсу и размеру -- в WorldResourceConfig; новая точка -- того же размера;
старые сохранения дополняются до таблицы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:08:51 +01:00
AlexanderandClaude Opus 5.5 5c4ba98fef Разгром дальше 5:1 (решения 31.10, п. 5): 7:1 -- 92%, 10:1 -- 95%, дальше плавно к 99%
Сильная сторона после 5:1 -- 11% (документ не задаёт). Версия правил боя 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:07:14 +01:00
AlexanderandClaude Opus 5.5 20691e0511 Скорость смешанного войска -- средняя по численности с модификаторами (решения 31.10, п. 4)
V = Σ(Nᵢ·Vᵢ·Mᵢ)/ΣNᵢ, общий модификатор -- один раз, без промежуточного округления.
MarchConfig: mixedUsesSlowest убран, добавлены typeSpeedBonusPercent и armySpeedBonusPercent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:05:57 +01:00
AlexanderandClaude Opus 5.5 b9ce85cdbb Туман М1 по решениям 31.10 (п. 1): гарнизоны, запасы точек и прогнозы -- только в зоне обзора
Объекты остаются ориентирами, закрытые сведения вне обзора -- «неизвестно».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:01:21 +01:00
AlexanderandClaude Opus 5.5 ed4e661cce Слияние с feature/settlement-population (Вальдемар, 31.10): Население снова отдельный модуль, рабочие без 40%, резерв по Ратуше, голод режет добычу, окна зданий и уровни Казарм
Конфликты только в документах (Core.md, README.md) — сохранены обе стороны,
в «Кто отвечает» у Саши добавлена Разведка.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:43:21 +01:00
AlexanderandClaude Opus 5.5 54036eafcf Разведка М1: обратный путь открывает туман и после офлайна
Мир досчитывается по событиям (AdvanceInSteps), Разведка -- до каждого:
до расчёта боя войско считалось стоящим у цели всё время возвращения.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:19:39 +01:00
AlexanderandClaude Opus 5.5 d5747f845d Разведка М1: туман на карте темнее (неисследованное 220, исследованное 110)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:15:47 +01:00
AlexanderandClaude Opus 5.5 552694f882 Разведка М1: туман, обзор, чужие армии, последний контакт; патрули-заглушки
- Новый модуль Bogatyri.Intelligence (без Unity): туман в трёх состояниях,
  обзор поселения 5 / армии 1 клетка, «Обзор» +10/20/30% (заменяет),
  примерная численность ±20%, опознание 50% на событие наблюдения,
  последний контакт 10 реальных минут, офлайн-досчёт шагами 10 с,
  сохранение в секции intelligence.
- Мир: IWorldMap (армии в момент времени), WorldAdvancingEvent,
  патрули-заглушки соседей (временно, выключаются в MarchConfig).
- Карта: IntelligenceOverlay — туман и метки чужих армий.
- Таблица IntelligenceConfig, 11 новых тестов, документы.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:05:17 +01:00
AlexanderandClaude Opus 5.5 c1832a2ae7 Панель армий: крупнее шрифт и строки — читается на телефоне
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:43:13 +01:00
AlexanderandClaude Opus 5.5 a7416ac99d Панель армий на карте мира (регламент 31.10)
Кнопка «Армии» под «Рейтингом» открывает окно WorldMap/ArmiesWindow.cs:
- вверху: дома свободно, гарнизон, походов N из M
- по каждому войску: куда и что делает (в бой / на сбор / собирает / возвращается),
  состав по типам, груз, время до цели или до конца сбора и когда будет дома
- таймеры обновляются сами, пока окно открыто

Окно атаки: для боя — длительность всего задания «туда и обратно ~X» (для сбора уже была).
Только наш слой карты мира; модули и код Валдемара не менялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:38:41 +01:00
AlexanderandClaude Opus 5.5 ded11b5a3f Поход помнит название цели: итог сбора с истощённой точки показывает её имя
При перемотке войско могло дойти, истощить точку и вернуться за один расчёт мира —
точка убиралась с карты раньше, чем показывались сообщения, и название было пустым.
MarchData.targetName запоминается при выступлении, WorldSimulation.TargetName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:31:12 +01:00
AlexanderandClaude Opus 5.5 ae9c141b49 Ресурсные точки на карте мира (регламент 31.10, наша часть по решению №46)
Мир (Bogatyri.World.Simulation, WorldSimulation.Points.cs):
- точки дерева, камня, металла, золота трёх размеров; запас, добыча воина за игровой час
  (1 игровой час = 5 реальных минут), груз на воина, срок жизни 12/24/48 реальных часов — ТЕСТ
- сбор: дошли → стоят на точке (MarchState.Gathering) до полного груза, истощения или исчезновения точки,
  затем физический возврат с грузом; всё считается в момент прихода — работает и офлайн
- осадные груза не несут; одна точка — одно войско; точка исчезла до прихода — возврат без добычи
- истощённая/исчезнувшая точка заменяется новой того же ресурса в другом месте (детерминированно)
- события GatherStarted, GatherFinished; порядок событий по времени с учётом сбора и исчезновения точек
- сохранение в секции world (points); старые сохранения без точек читаются

Зачисление груза — Экономика через IEconomy (как трофеи боя), код Валдемара не менялся.
Таблица WorldResourceConfig (Assets/Resources/WorldResourceConfig.asset).

Карта мира: точки — ромбы цвета ресурса с запасом и сроком; окно отправки с запасом, добычей,
грузом и прогнозом «унесут N за T»; окно «ТОЧКИ НЕТ»; подпись отряда «сбор», время с часами.
Город: окно «ВОЙСКО ВЕРНУЛОСЬ» пишет «Сбор … ресурсы собраны».

Открыто (не задано регламентом): число точек на карте (сейчас по 2 каждого ресурса), выбор размера,
предел добычи на точку. Позже: нападение на занятую точку, «Связь Волхвов», переполнение склада.

Тесты: появление и расстановка, сбор и груз, истощение и новая точка, осадные без груза, исчезновение
до прихода и во время сбора, одна точка — одно войско, сохранение посреди сбора. Все 54 теста проходят вне Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:28:50 +01:00
AlexanderandClaude Opus 5.5 e8b17a7efa Скорости маршей по типам войск и слоты похода (регламент 31.10)
Мир (Bogatyri.World.Simulation):
- MarchSpeedRules: скорость по типам — пехота 8, лучники 9, кавалерия 12, осадные 4 клетки
  за реальный час (ТЕСТ); марш идёт в реальном времени
- смешанное войско — по самому медленному типу (настройка mixedUsesSlowest: средняя по числу воинов);
  правило для одной армии в регламенте не задано — вопрос команде
- WorldSimulation.TravelTimeMs, AssignUnits: время прихода пересчитывается по составу похода;
  обратный путь — по составу выживших
- MarchRules.GetTravelTimeMs — для похода без состава (как пехота)

Армия (Bogatyri.Army):
- слоты похода: IArmy.MarchSlots (база 2, ТЕСТ), ActiveMarches; TakeForMarch отказывает при занятых слотах;
  походы из старых сохранений сверх лимита дойдут, новых не будет

Таблица MarchConfig (Assets/Resources/MarchConfig.asset): скорости, режим смешанного войска, база слотов.
Карта мира: путь в окне атаки — по составу при выбранном числе воинов, «Все слоты похода заняты»,
верхняя строка «отрядов: 1 из 2».

Тесты: скорости по типам, смешанное войско, обратный путь, слоты; старые тесты мира переведены
на часовые марши. Все 49 тестов ModuleTests проходят вне Unity. Код Валдемара не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:05:55 +01:00
AlexanderandClaude Opus 5.5 d9c0039bd2 Кривая потерь по регламенту 31.10 (решение №14)
Бой (модуль Battle):
- доля выбывших каждого типа — плавная функция соотношения итоговых сил (сильная / слабая)
  по опорным точкам регламента: 1:1 — 45% / 45%, 3:1 — 16% / 74%, 5:1 — 11% / 88% (ТЕСТ);
  между точками линейно, без порогов-переключателей; одинаково для PvE, PvP и Круга
- заменяет «проигравший теряет 50%, победитель до 50%»
- считается точной дробью, округление потерь — как прежде шансом (№13)
- за 5:1 — значения последней точки (рост разгрома дальше не задан — вопрос команде)

Таблица BattleConfig: точки кривой (lossRatioPermille, strongLossPermille, weakLossPermille)
вместо loserLossPercent; некорректная таблица — значения по умолчанию. Версия правил 4.

Раненых пока нет: все выбывшие — погибшие. Раненые (№15) — следующим шагом вместе с госпиталем,
нужен контракт с Населением (Валдемар), чтобы раненые учитывались среди людей города.

Тесты: опорные точки, симметрия, плавность и монотонность, кривая из таблицы и защита от ошибки;
тесты округления пересчитаны под кривую. Все 47 тестов ModuleTests проходят вне Unity.
Код Валдемара не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:54:37 +01:00
AlexanderandClaude Opus 5.5 8e9dbdf1cf Мир по документу 02: события строго по времени, время и номер боя в событиях, единицы таймеров
Порядок событий (офлайн — в причинно-следственном порядке):
- WorldSimulation.Advance каждый раз берёт самое раннее событие — бой или возвращение;
  раньше за один вызов шли сначала все бои, потом все возвращения, и войско,
  вернувшееся раньше чужого боя, попадало в события после него
- при равном времени — бой раньше возвращения, затем по номеру похода
- WorldEvent.TimeMs — когда событие случилось, а не когда его заметили

События похода (ядро):
- TimeMs у MarchStartedEvent, MarchBattleResolvedEvent, MarchReturnedEvent
- BattleId у MarchBattleResolvedEvent

Таймеры: явные единицы (реальные секунды/минуты часов GameClock), таблица в World.md;
скорость игрового календаря (№7) не утверждена — ускорения нет.

Тесты «Готово, когда» (документ 02), наша часть: порядок после офлайна, повторный вызов
без дублей, границы таймеров (ровно в срок, не раньше), 0 и 1 солдат.
Все 46 тестов ModuleTests проходят вне Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:11:15 +01:00
AlexanderandClaude Opus 5.5 6772292650 Бой по комплекту требований 29.10: шанс победы, сила без округления, округление потерь шансом, голод в офлайн-бою
Решения №11–13 (документ 01 комплекта):
- №11: прогноз показывает точный шанс победы (BattleForecast.WinChancePermille) —
  перебор всех 101×101 равновероятных сочетаний удачи; в окне атаки «шанс победы 27%»
- №12: сила считается точной дробью (BigInteger), удача — после всех модификаторов,
  округление одно и только для показа (3 пехоты против 1 кав. и 2 пех.: 13,00 вместо 12,96)
- №13: потери — целая часть гарантирована, дробная — шанс ещё одной потери из отдельного
  потока случайности (своя соль на сторону и тип), воспроизводимо по номеру боя;
  одиночка-проигравший теперь может погибнуть
- версия правил боя 3 (BattleRules, BattleConfig)

Голод на момент боя (№9, документ 02 — офлайн в причинно-следственном порядке):
- MarchManager спрашивает IPopulation.WasStarvingAt(время прихода войска)
- перед боем, прошедшим в прошлом, просит Население досчитать время (публичный Tick);
  модуль Населения не менялся

Формула потерь (№14) и раненые (№15) не тронуты — ждут чисел от команды.

Тесты: шанс победы совпадает с 20 000 настоящих боёв (±2%), сила без промежуточного округления,
округление потерь (одиночка гибнет ~50%, 1,5 → 1 или 2). Все 42 теста ModuleTests проходят вне Unity.
Документы Battle, World, Core, README обновлены (только наши строки).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:02:05 +01:00
88 changed files with 6678 additions and 338 deletions
+30 -2
View File
@@ -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
+22
View File
@@ -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:
+23
View File
@@ -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:
+26
View File
@@ -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
+8
View File
@@ -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:
+23 -4
View File
@@ -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)
+256 -13
View File
@@ -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)
{
+4 -1
View File
@@ -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;
+40
View File
@@ -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:
+109
View File
@@ -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)
{
+93 -1
View File
@@ -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)
+68
View File
@@ -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
};
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c25bbe993b314a15a6d1a8aaf0017d55
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+73
View File
@@ -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:
+82
View File
@@ -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));
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0427eef4612041d2b8a359f9bf358779
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+33
View File
@@ -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
+84
View File
@@ -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:
+5 -5
View File
@@ -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;
+120 -13
View File
@@ -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);
+52
View File
@@ -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
+203
View File
@@ -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
+60
View File
@@ -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:
+43 -47
View File
@@ -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();
}
// Потери в бою: убираем процент от каждого типа войск
+1 -1
View File
@@ -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;
+151 -39
View File
@@ -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
+332 -3
View File
@@ -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];
+21 -2
View File
@@ -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%
+247 -65
View File
@@ -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;
}
+4
View File
@@ -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)
{
+8
View File
@@ -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 минут + конец");
}
}
+152 -14
View File
@@ -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;
}
+10 -4
View File
@@ -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);
+204
View File
@@ -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
+11 -4
View File
@@ -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}";
}
}
+272 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`. |
+2
View File
@@ -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` «Люди» на ячейках панели. |
+116
View File
@@ -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`). |
+7 -4
View File
@@ -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
View File
@@ -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. |
+4 -1
View File
@@ -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`, если файла нет. |
+18 -1
View File
@@ -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
View File
@@ -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`), возвращение по неизвестному походу ничего не зачисляет. Тест отмены похода. |