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
vologdia 1a54890a09 Окна зданий, уровни Казарм и открытие войск по уровню Казарм; население: группы, найм, голод 2026-10-31 20:52:28 +03:00
vologdia ed01ba67bd Население снова самостоятельный модуль; рабочие без 40%, резерв по Ратуше, голод режет добычу (регламент 31.10) 2026-10-31 19:34:33 +03: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
vologdia bf2b193497 Слияние с feature/battle-famine (бой, голод, гарнизон, лагеря) 2026-10-28 22:21:42 +03:00
vologdia 10b58fee22 Население — часть Поселения: найм из жителей, история голода; стройка на сетке 2026-10-28 22:11:36 +03:00
AlexanderandClaude Opus 5.5 6db08851c9 README модулей: схема из десяти модулей (добавлено Население), «не путать», кто за что отвечает
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:33:41 +01:00
AlexanderandClaude Opus 5.5 5001144147 Окно атаки обновляется, если голод начался или кончился, пока оно открыто
Раньше предупреждение о голоде и прогноз считались один раз при открытии окна.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:20:12 +01:00
AlexanderandClaude Opus 5.5 5581b4d2c9 Голод в бою (−50%) и правило усталости (−20%, вместе 40%)
План от 27.10.2026, раздел «Армии и восстановление»:
- BattleCondition (флаги Famine, Fatigue); BattleRequest.AttackerConditions / DefenderConditions
- голод −50%, усталость −20%, применяются последовательно: вместе 40% силы
- порядок: контры и осадные → бонусы → состояния → случайность
- числа в BattleConfig (famineStrengthPercent, fatigueStrengthPercent) и BattleRules, версия правил 2
- BattleOutcome.AttackerConditionPercent / DefenderConditionPercent

Мир:
- голод проверяется на момент боя: WorldSimulation.AttackerConditions спрашивает MarchManager,
  тот берёт IPopulation.IsStarving
- бой в офлайне получает голод на момент входа в игру (истории голода у Населения нет) — записано в Battle.md

Интерфейс карты:
- окно атаки: «Голод: войско слабее, сила 50%. Накормите город до боя», прогноз учитывает голод
- окно итога боя: «голод: сила 50%»

Усталость пока никто не выставляет: не утверждено, как смешивать свежие и уставшие войска.

Тесты: штрафы и их сложение, голод переворачивает бой у нападающего и у защитника, прогноз с голодом,
голод в момент боя у похода. Все 39 тестов ModuleTests проходят вне Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:16:25 +01:00
AlexanderandClaude Opus 5.5 2e87b10a58 Окно итога боя: текст ужимается и не уходит под кнопку OK, строка силы и удачи короче
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:10:27 +01:00
AlexanderandClaude Opus 5.5 eeb90b8cb6 Боевой цикл, этап 2 плана: таблица баланса боя, случайность ±5%, прогноз риска, полный итог боя
Числа боя (ревью 27.10.2026, п. 2.5):
- BattleRules (модуль Боя, без Unity) — все числа в одном месте, с версией
- таблица BattleConfig (Assets/Resources/BattleConfig.asset) — меняется в Inspector, App/GameModules переводит её в BattleRules
- сила воинов по-прежнему из GameData.Troops

Случайность (план 27.10.2026):
- каждой стороне от −5% до +5% (шаг 0,1%), из номера боя BattleRequest.Seed (SplitMix64)
- тот же номер с теми же войсками — всегда тот же итог; поход получает battleSeed при выступлении и хранит его

Прогноз:
- IBattleSystem.Forecast: «преимущество / исход неясен / высокий риск поражения», диапазоны силы и потерь
- окно атаки показывает прогноз риска вместо точного итога; окно итога боя — силы и удачу сторон

Итог боя (ревью, п. 2.3):
- для обеих сторон по типам: до боя, выжившие, погибшие, раненые (пока нули — доля не утверждена)
- номер боя, версия правил, удача сторон

Тесты: случайность воспроизводима и в пределах ±5%; прогноз не противоречит 300 боям с разными номерами;
итог сходится по типам у обеих сторон; правила из таблицы; тот же поход — тот же бой.
Все 37 тестов ModuleTests прогнаны вне Unity — проходят.

Открыто для команды (Battle.md): показывать ли шанс победы в процентах; порядок бонусов и случайности.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:03:14 +01:00
vologdia ebb03489d0 Merge pull request 'Feature/battle troop types' (#16) from feature/battle-troop-types into develop
Reviewed-on: #16
2026-10-28 20:51:09 +03:00
AlexanderandClaude Opus 5.5 721881b844 Исправления по ревью 27.10.2026: безопасная отправка и возвращение войск
Армия (п. 2.1):
- ReturnFromMarch / ReturnUnits возвращают bool и зачисляют воинов только по известному походу и только один раз
  (раньше неизвестный поход добавлял выживших пехотой, ReturnUnits — без ограничения)
- вернуться может не больше, чем ушло
- TakeForMarch отклоняет повторный номер похода (раньше перезапись теряла воинов первого)
- загрузка сохранения и перенос старых данных пропускают дубликаты походов

Мир (п. 2.2):
- TryStartMarch проверяет TakeForMarch; при отказе поход откатывается (WorldSimulation.CancelMarch)
- если Армия не знает возвращающийся поход — ни воинов, ни добычи, предупреждение в консоли

Тесты (п. 2.1, 2.2, 2.4):
- неизвестный поход, повторное возвращение, повторный номер, откат похода
- крайние случаи боя: 1×1, 1×10, 10×1, 0×0, пустая сторона, бонусы −100% / −500% / +100000%, бой числом

Формула потерь не менялась. Найдено и записано в Battle.md как открытый вопрос:
одиночный проигравший не гибнет (потери округляются вниз) — правило округления решает команда.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:38:01 +01:00
AlexanderandClaude Opus 5.5 99b7d38b6e Окна города: содержимое в безопасной зоне
На iPhone левый край окон («Войска» и др.) уходил под вырез камеры.
BasePanel переносит Content в контейнер SafeArea; фон окна остаётся как был.
SafeArea.fitInsideParent: зона пересчитывается в координаты родителя, если он не на весь экран.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:02:04 +01:00
Alexander b11b97607f Слита ветка товарища feature/storage-workers: склады и лимиты, добыча через рабочих
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	Assets/Scripts/App/DebugPanel.cs
#	Docs/Modules/Core.md
#	Docs/Modules/README.md
#	Docs/Modules/Save.md
2026-10-27 16:57:28 +01:00
AlexanderandClaude Opus 5.5 4865b14ff2 Гарнизон города
Модуль Армии (Bogatyri.Army, без Unity):
- гарнизон — часть воинов в городе: защищает город и в походы не уходит, всего воинов столько же
- IArmy: GarrisonTotal, GetGarrison(type), AvailableForMarch, SetGarrison(count)
- гарнизон набирается по составу армии, не больше воинов в городе; после потерь обрезается по типам
- в поход уходят только свободные воины (TakeForMarch)
- сохранение: строка garrison= в секции army (старые сохранения — без гарнизона); сброс очищает
- TroopsChangedEvent.Garrison

Интерфейс:
- окно «Войска» в городе: карточка «Гарнизон города» (0, -10, -1, +1, +10, «Все»)
- карта мира: «Свободно / Гарнизон / В походе», ползунок похода и прогноз — только по свободным воинам
- понятная ошибка, если воины есть, но все в гарнизоне

Тест «один солдат в одном месте»: 30 воинов, 10 в гарнизон → 20 свободны, всего 30 (решение команды от 19.10.2026).
Документы Army, Core, World, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:54:49 +01:00
AlexanderandClaude Opus 5.5 b1a396e9a2 Лагеря разбойников (PvE) на карте мира
Модуль Мира (Bogatyri.World.Simulation, без Unity):
- 12 лагерей трёх уровней: Стан разбойников, Логово лиходеев, Становище Соловья-разбойника
  (гарнизон 6–9 / 14–17 / 26–29, добыча — еда, дерево, золото, металл)
- ставятся отдельным генератором после городов: замок, твой город и соседи остались на местах
- бой в поле (без стен), у разбойников нет осадных
- после победы лагерь разбит на 30 минут (CampRules.RespawnMs), напасть нельзя
- бои считаются по времени прихода: второе войско к разбитому лагерю — без боя, все домой, добычи нет
- секция сохранения world хранит разбитые лагеря (camps); сброс — все лагеря на месте
- MarchReturnedEvent.TargetEmpty, окно в городе пишет «лагерь уже был разбит»

Интерфейс карты:
- лагерь — фиолетовый круг с подписью «название, ур. N, гарнизон»; разбитый — серый с таймером
- окно атаки: уровень лагеря, «бой в поле» / «за стенами», прогноз с учётом стен
- окно «ЛАГЕРЬ ПУСТ», подсказка при нажатии на разбитый лагерь

Тесты: расстановка лагерей, бой в поле без осадных, разбит → второе войско находит пустым → сохранение → появляется снова.
Документы World, Battle, Save, Core, README обновлены. Числа требуют балансировки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:46:54 +01:00
AlexanderandClaude Opus 5.5 fa7ca1a28d DEV-панель: кнопка DEV возвращена на прежнее место
В безопасной зоне она мешала кнопке HUD. Панель осталась в безопасной зоне, кнопка «Закрыть» тоже.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:41:26 +01:00
AlexanderandClaude Opus 5.5 c7013508a4 DEV-панель: кнопка в безопасной зоне, ярче, кнопка «Закрыть»
На iPhone кнопку DEV у левого края закрывал вырез камеры, и открытую панель нельзя было закрыть.
Кнопка и панель теперь внутри SafeArea, кнопка ярче, в панели есть «Закрыть».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:39:46 +01:00
AlexanderandClaude Opus 5.5 20b88a6cc2 Убраны случайно попавшие в коммит файлы редактора (сцена MVP1, шрифт, .vscode, slnx)
Их изменил сам Unity, к DEV-панели они не относятся. Локальные изменения остались в рабочей копии.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:37:23 +01:00
AlexanderandClaude Opus 5.5 a43d6312f3 DEV-панель: комментарий — перемотка теперь двигает и походы
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:37:10 +01:00
AlexanderandClaude Opus 5.5 460d1db2de Боевая система с типами войск
Модуль Боя (Bogatyri.Battle, без Unity и float):
- сила стороны по типам войск, сила воина — из GameData.Troops (4/5/9/16)
- контры +25%: пехота > кавалерия > лучники > пехота (по составу врага)
- стены +20% защитникам; осадные +50% против стен, −25% в поле
- потери по типам: проигравший — половина, победитель — до половины (чем ровнее бой, тем больше)
- BattleRequest/BattleOutcome расширены (состав, стены, бонусы, сила сторон); бой «числом» считается пехотой
- формула — предложение, числа константами в BattleSystem.cs, ждёт утверждения командой

Мир и Армия:
- состав гарнизона у каждой цели (WorldMapLayout.GarrisonUnits, из id — карта не изменилась)
- поход хранит состав по типам; выжившие возвращаются точно по типам (IArmy.GetMarchUnits/ReturnUnits)
- время походов по GameClock: перемотка в DEV-панели проматывает и походы

Интерфейс карты:
- окно атаки: состав гарнизона и ожидаемый исход (сила сторон, победа/поражение, потери)
- верхняя панель: воины в походах (а не число отрядов)

Тесты: контры, стены, осадные, потери в пределах состава, состав гарнизонов.
Документы Battle, World, Army, Core, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:34:58 +01:00
vologdia f188c3a8eb Merge pull request 'Feature/economy' (#14) from feature/economy into develop
Reviewed-on: #14
2026-10-27 18:10:46 +03:00
104 changed files with 10119 additions and 499 deletions
+51
View File
@@ -0,0 +1,51 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
m_Name: BattleConfig
m_EditorClassIdentifier:
version: 7
counterPercent: 25
wallsPercent: 20
siegeVsWallsPercent: 50
siegeInFieldPercent: -25
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
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b666973e44a2442b82f2782adc99852f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+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:
+86
View File
@@ -0,0 +1,86 @@
using UnityEngine;
// ТАБЛИЦА БАЛАНСА БОЯ. Все числа боевой системы -- здесь, а не в коде модуля (ревью от 27.10.2026, п. 2.5).
//
// Ассет: Assets/Resources/BattleConfig.asset (выдели его в окне Project и меняй числа в Inspector,
// перекомпиляция не нужна; изменения действуют со следующего запуска игры). Создать заново:
// правый клик в Project -> Create -> Bogatyri -> Battle Config, имя файла -- BattleConfig, папка -- Resources.
//
// Модуль Боя таблицу не видит: App/GameModules переводит её в BattleRules.
// Сила воинов -- в GameData.Troops (рядом с ценой и временем обучения), здесь её нет.
// Числа -- стартовые для тестирования (план от 27.10.2026), утверждает команда.
[CreateAssetMenu(fileName = "BattleConfig", menuName = "Bogatyri/Battle Config")]
public class BattleConfig : ScriptableObject
{
[Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")]
public int version = 4;
[Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")]
public int counterPercent = 25;
[Tooltip("Стены, %: усиление защитников замка и городов")]
public int wallsPercent = 20;
[Tooltip("Осадные при штурме стен, %")]
public int siegeVsWallsPercent = 50;
[Tooltip("Осадные в поле и в обороне, % (минус)")]
public int siegeInFieldPercent = -25;
[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%")]
public int fatigueStrengthPercent = 80;
static BattleConfig cached;
public static BattleConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<BattleConfig>("BattleConfig");
if (cached == null)
{
Debug.LogWarning("[БОЙ] Нет Assets/Resources/BattleConfig.asset -- берутся числа по умолчанию из BattleConfig.cs.");
cached = CreateInstance<BattleConfig>();
}
return cached;
}
// Правила для модуля Боя
public BattleRules ToRules(int[] unitPower)
{
return new BattleRules
{
Version = version,
UnitPower = unitPower,
CounterPercent = counterPercent,
WallsPercent = wallsPercent,
SiegeVsWallsPercent = siegeVsWallsPercent,
SiegeInFieldPercent = siegeInFieldPercent,
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)
};
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7264ce138a844adc81fa69b77a27c8a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+631 -70
View File
@@ -1,22 +1,38 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Окно здания и рабочих (этап 3). Открывается тапом по зданию или кнопкой "Рабочие" в панели DEV.
// - сверху: что это за здание, его уровень и кнопка "Улучшить" (больше мест и эффективность / больше лимит / больше жилья);
// - ниже: все профессии с "−" и "+", сколько рабочих стоит, сколько мест и сколько добывается в час;
// - внизу: "Свободно: N из M".
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль добычи.
// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь).
// У каждого здания своё окно:
// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+",
// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена;
// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена;
// - Дом: сколько жилья даёт, население / предел, улучшение и цена;
// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения;
// - Кристалл: где добывается, запас / лимит;
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
// кнопка "Нанять войска" (панель "Войска").
// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы.
// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне.
// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11).
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
public static class BuildingWindow
{
static GameObject root;
static GameObject focus; // здание, по которому тапнули (null -- просто список рабочих)
static GameObject focus; // здание, по которому тапнули (null -- список рабочих)
static readonly List<Action> live = new List<Action>(); // что обновлять, пока окно открыто
public static void OnWorkplaceTapped(ResourceCollector building) { Open(building != null ? building.gameObject : null); }
public static void OnStorageTapped(StorageBuilding building) { Open(building != null ? building.gameObject : null); }
public static void OnTownHallTapped(TownHall townHall) { Open(townHall != null ? townHall.gameObject : null); }
public static void Open(GameObject building)
{
// В режиме стройки тап -- это выбор участка, а не здания
// В режиме стройки тап -- это выбор места, а не здания
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
focus = building;
Rebuild();
@@ -24,135 +40,680 @@ public static class BuildingWindow
public static void Close()
{
if (root != null) Object.Destroy(root);
if (root != null) UnityEngine.Object.Destroy(root);
root = null;
focus = null;
live.Clear();
}
static void Rebuild()
{
if (root != null) Object.Destroy(root);
if (root != null) UnityEngine.Object.Destroy(root);
live.Clear();
var card = UIKit.Modal(TitleFor(focus), out root);
root.AddComponent<BuildingWindowTicker>();
if (focus != null) BuildingSection(card, focus);
WorkersSection(card);
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
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;
if (focus == null && FocusWasDestroyed) { Close(); return; }
foreach (var update in live) update();
}
static bool focusSet;
static bool FocusWasDestroyed => focusSet && focus == null;
static string TitleFor(GameObject building)
{
focusSet = building != null;
if (building == null) return "РАБОЧИЕ";
var townHall = building.GetComponent<TownHall>();
if (townHall != null) return $"РАТУША — {townHall.currentLevel} УР.";
var id = building.GetComponent<PlacedBuildingId>();
var leveled = building.GetComponent<ILeveledBuilding>();
string name = id != null && !string.IsNullOrEmpty(id.buildingName) ? id.buildingName : building.name;
return leveled != null ? $"{name.ToUpper()} — {leveled.Level} УР." : name.ToUpper();
}
// ---------------- Здание: что даёт и улучшение ----------------
// ---------------- Добывающее здание ----------------
static void BuildingSection(RectTransform card, GameObject building)
static void WorkplaceSection(RectTransform card, ResourceCollector rc)
{
if (!WorkJobs.TryFromResource(rc.resourceType, out WorkJob job)) return;
var production = GameModules.Production;
if (production == null || !GameServices.TryGet(out IPopulation population) || !GameServices.TryGet(out IEconomy economy))
{
UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
return;
}
ResourceType res = WorkJobs.Resource(job);
string resName = GameData.ResName(res).ToLower();
double norm = production.Rules.PerWorkerPerHour[(int)job];
// Что даёт это здание
int slots = production.BuildingSlots(rc.level);
double eff = production.BuildingEfficiency(rc.level);
UIKit.Text(card, $"Здание даёт {slots} мест, норма ×{eff:0.##} — до {slots * norm * eff:0.#} {resName} в час при полном штате.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
// Рабочие профессии и кнопки
Header(card, $"Рабочие: {WorkJobs.Name(job).ToLower()}");
Live(card, () => $"{population.GetWorkers(job)} / {production.Slots(job)} мест в городе • свободных жителей: {population.FreeWorkers}",
26, UIKit.Theme.Text);
var row = UIKit.Row(card, 72, 8);
StepButton(row, "−5", job, -5);
StepButton(row, "−1", job, -1);
StepButton(row, "+1", job, +1);
StepButton(row, "+5", job, +5);
// Добыча и склад
Header(card, "Добыча и склад");
Live(card, () =>
{
float rate = production.RatePerHour(res);
string famine = population.IsStarving ? " <color=#ff7a7a>голод: добыча ×0.5</color>" : "";
return $"Сейчас: <color=#9be29b>+{rate:0.#} {resName} в час</color>{famine}";
}, 26, UIKit.Theme.Text);
StorageBar(card, economy, res);
Live(card, () => FillTimeText(economy, res, production.RatePerHour(res)), 22, UIKit.Theme.TextDim);
// Улучшение
int nextSlots = production.BuildingSlots(rc.level + 1);
double nextEff = production.BuildingEfficiency(rc.level + 1);
UpgradeBlock(card, rc.level,
$"мест {slots} → {nextSlots}, норма ×{eff:0.##} → ×{nextEff:0.##}",
() => rc.Level = rc.level + 1);
UIKit.Button(card, "Все рабочие", UIKit.Theme.Blue, () => Open(null), -1, 70, 24);
}
// ---------------- Дом ----------------
static void HouseSection(RectTransform card, ResourceCollector house)
{
int perLevel = EconomyConfig.Load().population.housingPerHouseLevel;
UIKit.Text(card, $"Дом даёт +{perLevel * house.level} к пределу жилья (+{perLevel} за уровень).", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IPopulation population))
{
Header(card, "Население");
LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f,
() => $"{population.Total} / {population.HousingCap} человек", UIKit.Theme.Blue);
Live(card, () => population.Total >= population.HousingCap
? "Предел жилья: прирост стоит, нужны Дома или Ратуша выше"
: $"Прирост: +{population.GrowthPerHour:0.#} человека в час", 22, UIKit.Theme.TextDim);
}
UpgradeBlock(card, house.level, $"жильё +{perLevel * house.level} → +{perLevel * (house.level + 1)}", () => house.Level = house.level + 1);
}
// ---------------- Кристалл ----------------
static void CrystalSection(RectTransform card)
{
UIKit.Text(card, "Синий кристалл добывается в кристальных разломах на карте мира. Здание не улучшается.", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IEconomy economy)) StorageBar(card, economy, ResourceType.Gems);
}
// ---------------- Склад и Амбар ----------------
static void StorageSection(RectTransform card, StorageBuilding storage)
{
var s = EconomyConfig.Load().storage;
bool warehouse = storage.kind == StorageBuilding.Kind.Warehouse;
float perLevel = warehouse ? s.perWarehouseLevel : s.perGranaryLevel;
UIKit.Text(card, warehouse
? $"Склад даёт +{perLevel * storage.level:0} к лимиту дерева, камня, металла и золота."
: $"Амбар даёт +{perLevel * storage.level:0} к лимиту еды.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IEconomy economy))
{
Header(card, "Запас / лимит");
if (warehouse)
{
StorageBar(card, economy, ResourceType.Wood);
StorageBar(card, economy, ResourceType.Stone);
StorageBar(card, economy, ResourceType.Iron);
StorageBar(card, economy, ResourceType.Gold);
}
else StorageBar(card, economy, ResourceType.Food);
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
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}",
() => storage.Level = storage.level + 1);
}
// ---------------- Ратуша ----------------
static void TownHallSection(RectTransform card, TownHall townHall)
{
var config = EconomyConfig.Load();
var rc = building.GetComponent<ResourceCollector>();
var storage = building.GetComponent<StorageBuilding>();
var leveled = building.GetComponent<ILeveledBuilding>();
string info;
int level = townHall.currentLevel;
int next = level + 1;
var p = config.population;
if (rc != null && WorkJobs.TryFromResource(rc.resourceType, out WorkJob job) && GameModules.Production != null)
{
var p = GameModules.Production;
info = $"Здание даёт {p.BuildingSlots(rc.level)} мест, норма ×{p.BuildingEfficiency(rc.level):0.##}.\n" +
$"Всего мест «{WorkJobs.Name(job)}» в городе: {p.Slots(job)}.";
}
else if (rc != null && rc.IsHouse)
{
int perLevel = config.population.housingPerHouseLevel;
string cap = GameServices.TryGet(out IPopulation pop) ? $"\nНаселение: {pop.Total} / {pop.HousingCap}" : "";
info = $"Дом: +{perLevel * rc.level} к пределу жилья (+{perLevel} за уровень).{cap}";
}
else if (rc != null && rc.resourceType == ResourceType.Gems)
{
info = "Синий кристалл добывается в кристальных разломах на карте мира.";
}
else if (storage != null)
{
var s = config.storage;
info = storage.kind == StorageBuilding.Kind.Warehouse
? $"Склад: +{s.perWarehouseLevel * storage.level:0} к лимиту дерева, камня, металла и золота."
: $"Амбар: +{s.perGranaryLevel * storage.level:0} к лимиту еды.";
}
else info = "";
UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (info.Length > 0) UIKit.Text(card, info, 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IPopulation population))
{
Header(card, "Город");
LiveBar(card, () => population.HousingCap > 0 ? (float)population.Total / population.HousingCap : 0f,
() => $"Население {population.Total} / {population.HousingCap}", UIKit.Theme.Blue);
Live(card, () => $"Жителей {population.Civilians}: рабочих {population.Workers}, свободных {population.Idle} • воинов {population.Soldiers}",
22, UIKit.Theme.TextDim);
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
}
if (leveled == null || (rc != null && rc.resourceType == ResourceType.Gems)) return;
if (leveled.Level >= config.upgrade.maxLevel)
if (level >= townHall.maxLevel)
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
}
var cost = config.UpgradeCost(leveled.Level);
bool canPay = GameServices.TryGet(out IEconomy economy) && economy.CanAfford(cost);
var b = UIKit.Button(card, $"Улучшить до {leveled.Level + 1} ур. ({CostText(cost)})", canPay ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
// Что даст следующий уровень
Header(card, $"Уровень {next}");
var gains = new List<string> { $"жильё +{p.housingPerTownHallLevel}" };
int nowReserve = ReserveAt(level), nextReserve = ReserveAt(next);
if (nextReserve != nowReserve) gains.Add($"резерв для найма {nowReserve} → {nextReserve}");
UIKit.Text(card, string.Join(", ", gains.ToArray()), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
string unlocks = UnlocksAt(next);
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
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; }
if (ms > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
leveled.Level = leveled.Level + 1;
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Улучшено до {leveled.Level} уровня");
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)
{
var gp = GameProgress.Instance;
UIKit.Text(card, $"Казармы обучают войска. Казарм в городе: {Barracks.Count}, мест в очереди: {Barracks.QueueSize}. " +
$"Новые войска открывает уровень самых прокачанных Казарм ({Barracks.MaxLevel} ур.) вместе с уровнем Ратуши.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (gp != null)
{
Header(card, "Обучение");
Live(card, () =>
{
var 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);
Header(card, "Войска");
for (int i = 0; i < GameData.Troops.Count; i++)
{
string lockText = gp.TroopLockText(i);
UIKit.Text(card, lockText.Length == 0
? $"<color=#9be59b>{GameData.Troops[i].name} — открыты</color>"
: $"{GameData.Troops[i].name} — нужно: {lockText}",
24, lockText.Length == 0 ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center);
}
}
// Что откроет следующий уровень этих Казарм
var opens = new List<string>();
if (gp != null)
for (int i = 0; i < GameData.Troops.Count; i++)
if (gp.RequiredBarracksLevel(i) == barracks.level + 1 && Barracks.MaxLevel <= barracks.level) opens.Add(GameData.Troops[i].name);
string effect = opens.Count > 0
? $"откроются: {string.Join(", ", opens.ToArray())} (если хватает уровня Ратуши)"
: "ближе к новым войскам";
UpgradeBlock(card, barracks.level, effect, () => barracks.Level = barracks.level + 1);
UIKit.Button(card, "Нанять войска", UIKit.Theme.Blue, () =>
{
Close();
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
}, -1, 80, 26);
}
// ---------------- Госпиталь (решения 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)
{
var table = GameModules.Population != null ? GameModules.Population.Rules.CivilianReserveByTownHallLevel : null;
if (table == null || table.Length == 0) return 0;
return table[Mathf.Clamp(townHallLevel, 1, table.Length) - 1];
}
// Здания и войска, которые открываются ровно на этом уровне Ратуши
static string UnlocksAt(int townHallLevel)
{
var names = new List<string>();
if (BuildManager.Instance != null && BuildManager.Instance.allBuildings != null)
foreach (var b in BuildManager.Instance.allBuildings)
if (b != null && b.requiredTownHallLevel == townHallLevel) names.Add(b.buildingName);
foreach (var t in GameData.Troops)
if (t.requiredTownHall == townHallLevel) names.Add(t.name);
return string.Join(", ", names.ToArray());
}
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel, int maxLevel = 0)
{
var config = EconomyConfig.Load();
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; }
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)
{
GameServices.TryGet(out IEconomy economy);
var parts = new string[cost.Length];
for (int i = 0; i < cost.Length; i++) parts[i] = $"{cost[i].Amount:0} {GameData.ResName(cost[i].Type).ToLower()}";
for (int i = 0; i < cost.Length; i++)
{
float have = economy != null ? economy.Get(cost[i].Type) : 0f;
bool ok = have >= cost[i].Amount;
string name = GameData.ResName(cost[i].Type).ToLower();
parts[i] = ok
? $"<color=#9be59b>{cost[i].Amount:0}</color> {name}"
: $"<color=#ff7a7a>{cost[i].Amount:0}</color> {name} (есть {have:0})";
}
return string.Join(", ", parts);
}
// ---------------- Рабочие по профессиям ----------------
// ---------------- Склад: полоска и время до заполнения ----------------
static void WorkersSection(RectTransform card)
static void StorageBar(RectTransform card, IEconomy economy, ResourceType res)
{
string name = GameData.ResName(res);
LiveBar(card, () =>
{
float cap = economy.GetCapacity(res);
return float.IsInfinity(cap) || cap <= 0 ? 0f : economy.Get(res) / cap;
},
() =>
{
float cap = economy.GetCapacity(res);
return float.IsInfinity(cap) ? $"{name}: {economy.Get(res):0}" : $"{name}: {economy.Get(res):0} / {cap:0}";
},
new Color(0.85f, 0.65f, 0.25f));
}
static string FillTimeText(IEconomy economy, ResourceType res, float ratePerHour)
{
float cap = economy.GetCapacity(res), have = economy.Get(res);
if (float.IsInfinity(cap)) return "";
if (have >= cap) return "<color=#ff7a7a>Склад полон — добыча пропадает. Улучшите Склад или Амбар.</color>";
if (ratePerHour <= 0) return "Добычи нет — поставьте рабочих";
float hours = (cap - have) / ratePerHour;
return hours < 1f ? $"Склад заполнится через {Mathf.CeilToInt(hours * 60)} мин" : $"Склад заполнится через {hours:0.#} ч";
}
// ---------------- Список рабочих по всем профессиям ----------------
static void WorkersList(RectTransform card)
{
if (!GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod))
{
UIKit.Text(card, "Модули Населения и добычи не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
UIKit.Text(card, "Модули Населения и Экономики не подключены", 26, UIKit.Theme.Red, TextAlignmentOptions.Center);
return;
}
for (int j = 0; j < WorkJobs.Count; j++)
{
var job = (WorkJob)j;
int slots = prod.Slots(job);
int workers = pop.GetWorkers(job);
var row = UIKit.Row(card, 72, 8);
string rate = slots > 0 ? $" <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>" : "";
var label = UIKit.Text(row, slots > 0 ? $"{WorkJobs.Name(job)}: {workers} / {slots}{rate}" : $"{WorkJobs.Name(job)}: нет зданий",
24, slots > 0 ? UIKit.Theme.Text : UIKit.Theme.TextDim);
var label = Live(row, () =>
{
int slots = prod.Slots(job);
if (slots <= 0) return $"{WorkJobs.Name(job)}: нет зданий";
return $"{WorkJobs.Name(job)}: {pop.GetWorkers(job)} / {slots} <color=#9be29b>+{prod.RatePerHour(WorkJobs.Resource(job)):0.#}/ч</color>";
}, 24, UIKit.Theme.Text);
label.enableAutoSizing = true; label.fontSizeMin = 14; label.fontSizeMax = 24;
label.alignment = TextAlignmentOptions.Left;
UIKit.Size(label, -1, 72, 1);
StepButton(row, "−5", job, -5, slots > 0 && workers > 0);
StepButton(row, "−1", job, -1, slots > 0 && workers > 0);
StepButton(row, "+1", job, +1, slots > 0 && workers < slots && pop.FreeWorkers > 0);
StepButton(row, "+5", job, +5, slots > 0 && workers < slots && pop.FreeWorkers > 0);
StepButton(row, "−5", job, -5);
StepButton(row, "−1", job, -1);
StepButton(row, "+1", job, +1);
StepButton(row, "+5", job, +5);
}
UIKit.Text(card, $"Свободно: <b>{pop.FreeWorkers}</b> из {pop.Workforce} рабочих • остальные жители: {Mathf.Max(0, pop.Civilians - pop.Workforce)}",
24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
Live(card, () => $"Свободных жителей: <b>{pop.FreeWorkers}</b> из {pop.Civilians}", 24, UIKit.Theme.Gold);
}
static void StepButton(Transform row, string label, WorkJob job, int delta, bool enabled)
// Кнопка "−" / "+": активна, только если есть кого снять или куда и кого поставить
static void StepButton(Transform row, string label, WorkJob job, int delta)
{
var b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
Button b = null;
b = UIKit.Button(row, label, delta > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
{
if (GameServices.TryGet(out IPopulation pop)) pop.SetWorkers(job, pop.GetWorkers(job) + delta);
Rebuild();
Tick();
}, 86, 64, 24);
b.interactable = enabled;
live.Add(() =>
{
if (b == null || !GameServices.TryGet(out IPopulation pop) || !GameServices.TryGet(out IProduction prod)) return;
int workers = pop.GetWorkers(job), slots = prod.Slots(job);
b.interactable = delta < 0 ? workers > 0 : slots > workers && pop.FreeWorkers > 0;
});
}
// ---------------- Мелочи ----------------
static void Header(RectTransform card, string text)
{
UIKit.Text(card, text, 28, UIKit.Theme.Gold, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
}
// Текст, который обновляется, пока окно открыто
static TextMeshProUGUI Live(Transform parent, Func<string> text, float size, Color color)
{
var t = UIKit.Text(parent, text(), size, color, TextAlignmentOptions.Center);
live.Add(() => { if (t != null) t.text = text(); });
return t;
}
static void LiveBar(RectTransform card, Func<float> fraction, Func<string> label, Color color)
{
var bar = UIKit.Bar(card, fraction(), color, 34, label());
live.Add(() => { if (bar != null) UIKit.SetBar(bar, fraction(), label()); });
}
}
// Обновляет открытое окно здания раз в полсекунды (добыча идёт, склад наполняется)
public class BuildingWindowTicker : MonoBehaviour
{
float next;
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
BuildingWindow.Tick();
}
}
+8 -13
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,26 +114,19 @@ 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;
if (production == null || population == null) return;
// Места для рабочих: где мест стало больше (построили, улучшили) -- ставим туда свободных
var before = new int[WorkJobs.Count];
for (int j = 0; j < WorkJobs.Count; j++) before[j] = production.Slots((WorkJob)j);
// Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам
// не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем.
production.SetBuildings(city.Workplaces);
population.SetHouseLevels(city.HouseLevels);
population.ClampJobs();
if (!population.JobsInitialized)
{
population.AutoFillAll(); // первый запуск после этапа 3 или новая игра
}
else
{
for (int j = 0; j < WorkJobs.Count; j++)
if (production.Slots((WorkJob)j) > before[j]) population.AutoFill((WorkJob)j);
}
// Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения
if (!population.JobsInitialized) population.AutoFillAll();
}
}
+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:
+13 -4
View File
@@ -9,8 +9,8 @@ using UnityEngine.UI;
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
//
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение
// войск пока идут по реальным часам.
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение
// войск пока идёт по реальным часам.
public class DebugPanel : MonoBehaviour
{
static DebugPanel instance;
@@ -38,15 +38,23 @@ public class DebugPanel : MonoBehaviour
void Build()
{
// Панель -- внутри безопасной зоны, чтобы не уходила под вырез камеры
var safe = new GameObject("SafeArea", typeof(RectTransform)).GetComponent<RectTransform>();
safe.SetParent(transform, false);
safe.anchorMin = Vector2.zero;
safe.anchorMax = Vector2.one;
safe.offsetMin = safe.offsetMax = Vector2.zero;
safe.gameObject.AddComponent<SafeArea>();
// Кнопка-переключатель слева посередине: там нет кнопок HUD
var toggle = UIKit.Button(transform, "DEV", new Color(0.55f, 0.15f, 0.55f, 0.85f), Toggle, 110, 64, 26);
var toggle = UIKit.Button(transform, "DEV", new Color(0.8f, 0.2f, 0.8f, 1f), Toggle, 110, 64, 28);
var trt = (RectTransform)toggle.transform;
trt.anchorMin = trt.anchorMax = new Vector2(0, 0.5f);
trt.pivot = new Vector2(0, 0.5f);
trt.anchoredPosition = new Vector2(12, 0);
trt.sizeDelta = new Vector2(110, 64);
var card = UIKit.Card(transform, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10);
card.anchorMin = card.anchorMax = new Vector2(0, 0.5f);
card.pivot = new Vector2(0, 0.5f);
card.anchoredPosition = new Vector2(135, 0);
@@ -73,6 +81,7 @@ public class DebugPanel : MonoBehaviour
var row4 = UIKit.Row(card, 70, 10);
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
panel.SetActive(false);
}
+119 -1
View File
@@ -39,6 +39,9 @@ public class EconomyConfig : ScriptableObject
[Tooltip("Офлайн-прогресс считается не больше чем за столько часов. Не утверждено")]
public int maxOfflineHours = 48;
[Tooltip("Гражданский резерв по уровню Ратуши: [0] -- уровень 1, [1] -- уровень 2... Столько свободных жителей нельзя забрать в войско. Регламент 31.10, ТЕСТ: 1-2 -- 10, 3-5 -- 20, 6-10 -- 30")]
public int[] civilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
}
[Serializable]
@@ -60,6 +63,8 @@ public class EconomyConfig : ScriptableObject
public int workerSlotsPerLevel = 10;
[Tooltip("+ к норме за каждый уровень здания выше первого (0.1 = +10%). Не утверждено")]
public float efficiencyPerLevel = 0.1f;
[Tooltip("Голод: множитель добычи всех ресурсов, кроме еды (0.5 -- вдвое меньше). Еда добывается без штрафа. Регламент 31.10, ТЕСТ")]
[Range(0f, 1f)] public float famineNonFoodMultiplier = 0.5f;
}
[Serializable]
@@ -104,14 +109,60 @@ public class EconomyConfig : ScriptableObject
}
[Serializable]
public class SoldierSettings // этап 5: цена солдата (пока не используется)
public class SoldierSettings // этап 5: цена солдата
{
[Tooltip("Сколько жителей стоит один воин (берутся из жителей при начале обучения)")]
public int people = 1;
public float food = 15f;
public float iron = 5f;
public float gold = 5f;
[Tooltip("Время подготовки, секунд. Не утверждено")]
public int trainSeconds = 60;
[Tooltip("Уровень Казарм, с которого открывается тип войск (порядок как в GameData.Troops: пехота, лучники, кавалерия, осадные). " +
"Нужен и уровень Ратуши. Считаются самые прокачанные Казармы. ТЕСТ, не утверждено")]
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
}
[Serializable]
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]
@@ -147,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;
@@ -180,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)
{
+107 -5
View File
@@ -16,15 +16,46 @@ public static class GameModules
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register()
{
// Боевая система -- чистый C#, сама зарегистрироваться не может
GameServices.Register<IBattleSystem>(new BattleSystem());
// Боевая система -- чистый C#, сама зарегистрироваться не может.
// Числа боя -- из таблицы BattleConfig, сила воина каждого типа -- из GameData.Troops
var unitPower = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
GameServices.Register<IBattleSystem>(new BattleSystem(BattleConfig.Load().ToRules(unitPower)));
// Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица 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();
@@ -47,6 +78,20 @@ public static class GameModules
ResourceCollector.Tapped += BuildingWindow.OnWorkplaceTapped;
StorageBuilding.Tapped -= BuildingWindow.OnStorageTapped;
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()); // воины -- тоже люди
@@ -66,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);
@@ -75,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()
@@ -130,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)
@@ -146,7 +243,11 @@ public static class GameModules
HousingBase = p.housingBase,
HousingPerTownHallLevel = p.housingPerTownHallLevel,
HousingPerHouseLevel = p.housingPerHouseLevel,
MaxOfflineHours = p.maxOfflineHours
MaxOfflineHours = p.maxOfflineHours,
CivilianReserveByTownHallLevel = p.civilianReserveByTownHallLevel != null && p.civilianReserveByTownHallLevel.Length > 0
? (int[])p.civilianReserveByTownHallLevel.Clone()
: new PopulationRules().CivilianReserveByTownHallLevel,
PeoplePerSoldier = Mathf.Max(1, config.soldier.people)
};
}
@@ -159,7 +260,8 @@ public static class GameModules
PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour },
SlotsPerBuilding = p.workerSlotsT1,
SlotsPerLevel = p.workerSlotsPerLevel,
EfficiencyPerLevel = p.efficiencyPerLevel
EfficiencyPerLevel = p.efficiencyPerLevel,
FamineNonFoodMultiplier = p.famineNonFoodMultiplier
};
}
+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:
+3 -1
View File
@@ -16,7 +16,9 @@ public class CameraController : MonoBehaviour
void Update()
{
// --- Перемещение по зажатой левой кнопке мыши ---
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI()) // не тащим камеру, когда листаем панель
if (UIBlocker.WorldInputCaptured) _isDragging = false; // палец тащит призрак здания -- камера стоит
if (Input.GetMouseButtonDown(0) && !UIBlocker.IsPointerOverUI() && !UIBlocker.WorldInputCaptured) // не тащим камеру, когда листаем панель
{
_lastMousePosition = Input.mousePosition;
_isDragging = true;
+22 -3
View File
@@ -2,9 +2,8 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
// Полоска над зданием (этап 3): рабочие этой профессии / места в городе.
// Раньше показывала накопленный запас кликера -- кликер убран, добычу дают рабочие.
// У Дома и Кристалла рабочих нет -- там подпись уровня, полоска скрыта.
// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию).
// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается.
public class BuildingUI : MonoBehaviour
{
[Header("Настройки")]
@@ -14,8 +13,16 @@ public class BuildingUI : MonoBehaviour
float next;
public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true
void Start()
{
if (!ShowLabels)
{
HideLabel();
enabled = false;
return;
}
if (building == null)
{
Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!");
@@ -25,6 +32,18 @@ public class BuildingUI : MonoBehaviour
UpdateUI();
}
// Прячем холст с подписью и полоской. Сам объект здания не трогаем: компонент висит на его корне
void HideLabel()
{
foreach (Component c in new Component[] { stokText, progressBar })
{
if (c == null) continue;
var canvas = c.GetComponentInParent<Canvas>(true);
GameObject target = canvas != null && canvas.gameObject != gameObject ? canvas.gameObject : c.gameObject;
if (target != gameObject) target.SetActive(false);
}
}
void Update()
{
if (Time.unscaledTime < next) return;
+32 -10
View File
@@ -5,13 +5,25 @@ using TMPro;
// КАЗАРМЫ -- здание для найма войск.
// - Без казарм нанимать войска нельзя.
// - Каждые казармы дают +1 место в очереди обучения (1 казармы = 3 места, максимум 5).
// - Нажатие на казармы открывает панель "Войска" (найм).
// - Над зданием подпись: что сейчас обучается и сколько осталось.
// - Уровень Казарм открывает новые типы войск (вместе с уровнем Ратуши; числа -- EconomyConfig, раздел "Солдат", ТЕСТ).
// Считаются самые прокачанные Казармы. Уровень сохраняет модуль Поселения (ILeveledBuilding).
// - Тап по казармам открывает окно Казарм (App/BuildingWindow): очередь, войска, улучшение, кнопка "Нанять войска".
// - Подписи над зданием больше нет -- всё в окне (вернуть: ShowLabel = true).
// Вешается на префаб Barracks (его создаёт Tools -> Build HUD Panels).
public class Barracks : MonoBehaviour
public class Barracks : MonoBehaviour, ILeveledBuilding
{
public static readonly List<Barracks> All = new List<Barracks>();
public static int Count { get { return All.Count; } }
public static bool ShowLabel = false;
public int level = 1;
public int Level { get => level; set => level = Mathf.Max(1, value); }
// Уровень самых прокачанных Казарм в городе (0 -- казарм нет)
public static int MaxLevel
{
get { int m = 0; foreach (var b in All) if (b != null && b.level > m) m = b.level; return m; }
}
// Размер очереди обучения в зависимости от числа казарм
public static int QueueSize { get { return Count == 0 ? 0 : Mathf.Min(5, 2 + Count); } }
@@ -24,6 +36,7 @@ public class Barracks : MonoBehaviour
void Start()
{
if (!ShowLabel) return;
// подпись над зданием (обычный 3D-текст, не UI)
var go = new GameObject("BarracksLabel");
go.transform.SetParent(transform, false);
@@ -44,6 +57,7 @@ public class Barracks : MonoBehaviour
void Update()
{
if (label == null) return;
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
Refresh();
@@ -52,22 +66,30 @@ public class Barracks : MonoBehaviour
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;
void OnMouseDown()
{
downPos = Input.mousePosition;
}
// Тап (а не перетаскивание камеры) -- окно Казарм
void OnMouseUpAsButton()
{
if (UIBlocker.IsPointerOverUI()) return;
if (BuildModeController.Instance != null && BuildModeController.Instance.IsActive) return;
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Troops");
if ((Input.mousePosition - downPos).magnitude > 25f) return;
BuildingWindow.Open(gameObject);
}
}
+274 -69
View File
@@ -3,14 +3,22 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
// НОВАЯ СХЕМА СТРОЙКИ (как в Rise of Kingdoms):
// 1. Кнопка "СТРОЙКА" включает режим строительства -- появляются свободные участки и подсказка сверху.
// 2. Тап по свободному участку -> окно со зданиями (цена, что доступно по уровню Ратуши).
// 3. "Построить" -> здание встаёт на участок. Режим остаётся включённым, можно строить дальше.
// 4. Кнопка "ВЫЙТИ" (та же кнопка или в подсказке) -- выключает режим, участки снова прячутся.
// Вне режима пустые участки не видны и не мешают, при запуске игры ничего не открывается.
// СВОБОДНАЯ СТРОЙКА НА СЕТКЕ (игрок сам выбирает место):
// 1. Кнопка "СТРОЙКА" включает режим: на земле появляется сетка (красные клетки заняты), сверху подсказка,
// и сразу открывается список зданий.
// 2. Выбрали здание -> появляется призрак: полупрозрачное здание и пятно его клеток.
// Зелёное пятно -- можно ставить, красное -- занято или за границей сетки.
// 3. Призрак двигают пальцем (тащить за само пятно) или тапом по земле -- он прыгает туда.
// Камера в это время не ездит. Размер у зданий разный (BuildingData.footprint).
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
//
// 6. Лимит зданий по уровню Ратуши (EconomyConfig, ТЕСТ): в списке видно "2 / 3", сверх лимита строить нельзя.
// 7. Стройка идёт по времени и занимает строителя (BuildTimer): все заняты -- поставить нельзя.
// 8. Перенос (кнопка "Перенести" в окне здания): тот же призрак, но здание переезжает бесплатно.
//
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
public class BuildModeController : MonoBehaviour
{
public static BuildModeController Instance { get; private set; }
@@ -24,14 +32,28 @@ public class BuildModeController : MonoBehaviour
GameObject banner;
GameObject chooser;
GameObject placePanel;
TextMeshProUGUI placeInfo;
Button placeButton;
BuildGhost ghost;
GameObject moving; // здание, которое переносим (null -- строим новое)
bool dragging;
Vector2 grabOffset;
Vector2 downPos;
bool downOverUI;
float nextVisualRefresh;
bool downOnGhost;
float nextPanelRefresh;
static readonly Color SlotColor = new Color(0.3f, 1f, 0.4f, 0.75f);
const float TapThreshold = 25f;
void Awake() { Instance = this; }
void OnDestroy() { if (Instance == this) Instance = null; }
void OnDestroy()
{
if (Instance == this) Instance = null;
UIBlocker.WorldInputCaptured = false;
}
void Start()
{
@@ -45,85 +67,231 @@ public class BuildModeController : MonoBehaviour
{
IsActive = active;
CloseChooser();
ApplySlotVisuals();
CancelGhost();
UpdateButtonLabel();
BuildGrid.Instance.ShowOverlay(active);
if (banner != null) { Destroy(banner); banner = null; }
if (active)
{
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
banner = CreateBanner();
if (FreeSlots().Count == 0) UIKit.Toast("Свободных участков нет. Улучшайте Ратушу и сносите старое.");
OpenChooser();
}
}
void Update()
{
if (Time.unscaledTime >= nextVisualRefresh)
{
nextVisualRefresh = Time.unscaledTime + 0.3f;
ApplySlotVisuals(); // участки могли освободиться/заняться (снос, загрузка сохранения)
}
if (!IsActive) return;
// открыли любую панель HUD -- выходим из режима стройки
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") { SetMode(false); return; }
if (chooser != null) return;
if (chooser != null || ghost == null) { StopDrag(); return; }
HandlePointer();
if (Time.unscaledTime >= nextPanelRefresh)
{
nextPanelRefresh = Time.unscaledTime + 0.3f;
ghost.Recheck(); // клетки могли заняться (загрузка сохранения) или ресурсы -- прийти
RefreshPlacePanel();
}
}
// ---------------- палец: тащим призрак или тапаем по земле ----------------
void HandlePointer()
{
Vector2 world = PointerWorld();
// Тап = нажали и отпустили почти на том же месте (чтобы перетаскивание камеры не считалось тапом)
if (Input.GetMouseButtonDown(0))
{
downPos = Input.mousePosition;
downOverUI = UIBlocker.IsPointerOverUI();
downOnGhost = !downOverUI && ghost.Contains(world);
if (downOnGhost)
{
dragging = true;
grabOffset = (Vector2)ghost.transform.position - world;
UIBlocker.WorldInputCaptured = true; // камера не поедет
}
}
if (dragging && Input.GetMouseButton(0))
{
var before = ghost.Cell;
ghost.MoveNear(world + grabOffset);
if (ghost.Cell != before) RefreshPlacePanel();
}
if (Input.GetMouseButtonUp(0))
{
Vector2 up = Input.mousePosition;
if (downOverUI || UIBlocker.IsPointerOverUI() || (up - downPos).magnitude > 25f) return;
HandleTap(up);
bool wasDragging = dragging;
StopDrag();
if (wasDragging || downOverUI || UIBlocker.IsPointerOverUI()) return;
if (((Vector2)Input.mousePosition - downPos).magnitude > TapThreshold) return; // тащили камеру
ghost.MoveNear(world); // тап по земле -- призрак прыгает сюда
RefreshPlacePanel();
}
}
void HandleTap(Vector2 screenPos)
void StopDrag()
{
if (BuildManager.Instance == null) return;
var slot = BuildManager.Instance.SlotAtScreen(screenPos);
if (slot == null) return;
if (slot.isOccupied)
dragging = false;
UIBlocker.WorldInputCaptured = false;
}
static Vector2 PointerWorld()
{
var cam = Camera.main;
if (cam == null) return Vector2.zero;
Vector3 w = cam.ScreenToWorldPoint(Input.mousePosition);
return new Vector2(w.x, w.y);
}
// ---------------- призрак ----------------
// Перенос построенного здания: призрак на его месте, "Сюда" -- переезжает бесплатно
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)
{
var id = slot.occupyingBuilding != null ? slot.occupyingBuilding.GetComponent<PlacedBuildingId>() : null;
UIKit.Toast("Участок занят" + (id != null ? ": " + id.buildingName : ""));
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();
CloseChooser();
ghost = BuildGhost.Create(data);
// ставим в центр экрана, а если там занято -- на ближайшее свободное место
var cam = Camera.main;
Vector2 center = cam != null ? (Vector2)cam.transform.position : Vector2.zero;
if (BuildGrid.Instance.FindFreeNear(center, ghost.Footprint.size, out var cell)) ghost.MoveToCell(cell);
else
{
ghost.MoveNear(center);
UIKit.Toast("Свободного места под это здание нет");
}
CreatePlacePanel();
}
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);
placePanel = null;
}
void Place()
{
if (ghost == null || BuildManager.Instance == null) return;
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;
}
OpenChooser(slot);
}
// ---------------- участки ----------------
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; }
List<BuildSlot> FreeSlots()
{
var list = new List<BuildSlot>();
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None)) if (!s.isOccupied) list.Add(s);
return list;
}
void ApplySlotVisuals()
{
foreach (var s in FindObjectsByType<BuildSlot>(FindObjectsSortMode.None))
if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null)
{
var sr = s.GetComponent<SpriteRenderer>();
if (sr == null) continue;
bool show = IsActive && !s.isOccupied;
if (sr.enabled != show) sr.enabled = show;
if (show) sr.color = SlotColor;
UIKit.Toast(BuildTimer.DurationMs(false, 1) > 0 ? "Стройка началась: " + d.buildingName : "Построено: " + d.buildingName);
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
}
else UIKit.Toast("Не получилось построить");
}
// ---------------- нижняя панель: Поставить / Другое здание / Отмена ----------------
void CreatePlacePanel()
{
if (placePanel != null) Destroy(placePanel);
var bar = UIKit.Row(UIKit.Overlay, -1, 14, new Color(0.08f, 0.1f, 0.14f, 0.94f), 14);
bar.name = "BuildPlacePanel";
bar.anchorMin = new Vector2(0.04f, 0); bar.anchorMax = new Vector2(0.96f, 0);
bar.pivot = new Vector2(0.5f, 0);
bar.sizeDelta = new Vector2(0, 130); bar.anchoredPosition = new Vector2(0, 40);
placePanel = bar.gameObject;
placeInfo = UIKit.Text(bar, "", 26, Color.white, TextAlignmentOptions.Left);
UIKit.Size(placeInfo, -1, -1, 1);
placeButton = UIKit.Button(bar, 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();
}
void RefreshPlacePanel()
{
if (ghost == null || placeInfo == null) return;
var d = ghost.Data;
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) + BuildTimeText();
bool ok = ghost.CellsFree && afford && limit == null && builders;
string label = ok ? "Поставить" : !ghost.CellsFree ? "Занято" : limit != null ? "Лимит" : !builders ? "Строители заняты" : "Мало ресурсов";
UIKit.SetButton(placeButton, label, ok);
}
// ---------------- окно выбора здания ----------------
void OpenChooser(BuildSlot slot)
void OpenChooser()
{
CloseChooser();
StopDrag();
var bm = BuildManager.Instance;
if (bm == null) return;
GameObject root;
var card = UIKit.Modal("Что построить?", out root);
chooser = root;
@@ -135,7 +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, "Участок: " + slot.name + " • Ратуша " + 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;
@@ -144,14 +313,18 @@ public class BuildModeController : MonoBehaviour
// доступные сверху, закрытые по уровню Ратуши -- ниже
var all = new List<BuildingData>(bm.allBuildings);
all.RemoveAll(b => b == null);
all.RemoveAll(b => b == null || b.prefab == null);
all.Sort((a, b) => a.requiredTownHallLevel.CompareTo(b.requiredTownHallLevel));
foreach (var data in all)
{
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;
var row = UIKit.Row(list, 120, 14, locked ? UIKit.Theme.CardDark : UIKit.Theme.Card, 10);
var icon = UIKit.Box(row, d.icon != null ? Color.white : UIKit.Theme.Gray);
@@ -160,26 +333,57 @@ public class BuildModeController : MonoBehaviour
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, d.buildingName, 30, locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
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;
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 ? "Закрыто" : (afford ? "Построить" : "Мало ресурсов");
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () =>
{
if (BuildManager.Instance.BuildOnSlot(d, slot))
{
UIKit.Toast("Построено: " + d.buildingName);
CloseChooser();
ApplySlotVisuals();
if (FreeSlots().Count == 0) SetMode(false);
}
else UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost)));
}, 230, 90, 26);
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
string label = 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;
}
UIKit.Button(card, "Отмена", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
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()
@@ -216,12 +420,13 @@ public class BuildModeController : MonoBehaviour
// полоска сверху экрана (не модальная: остальной экран остаётся кликабельным)
var bar = UIKit.Row(UIKit.Overlay, -1, 16, new Color(0.1f, 0.25f, 0.12f, 0.92f), 14);
bar.name = "BuildModeBanner";
bar.anchorMin = new Vector2(0.15f, 1); bar.anchorMax = new Vector2(0.85f, 1);
bar.anchorMin = new Vector2(0.1f, 1); bar.anchorMax = new Vector2(0.9f, 1);
bar.pivot = new Vector2(0.5f, 1);
bar.sizeDelta = new Vector2(0, 110); bar.anchoredPosition = new Vector2(0, -150);
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: нажмите на зелёный участок", 30, Color.white, TextAlignmentOptions.Left);
var t = UIKit.Text(bar, "РЕЖИМ СТРОЙКИ: двигайте здание по сетке, красное -- занято", 26, Color.white, TextAlignmentOptions.Left);
UIKit.Size(t, -1, -1, 1);
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 180, 80, 28);
UIKit.Button(bar, "Здания", UIKit.Theme.Green, OpenChooser, 180, 80, 28);
UIKit.Button(bar, "Выйти", UIKit.Theme.Red, () => SetMode(false), 160, 80, 28);
return bar.gameObject;
}
+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:
+73 -31
View File
@@ -622,6 +622,12 @@ public class GameProgress : MonoBehaviour
// Войска хранит модуль Армии (Docs/Modules/Army.md) -- здесь только обращение к нему
static IArmy Army => GameServices.Get<IArmy>();
// Люди города -- модуль Населения. null -- модуль не подключён
static IPopulation Population => GameServices.TryGet(out IPopulation p) ? p : null;
// Сколько воинов можно нанять из жителей прямо сейчас (для кнопок найма)
public int Recruitable => Population != null ? Population.Recruitable : int.MaxValue;
public int TroopCount(int type) { return Army.GetTroops(type); }
public int TotalTroops
@@ -629,68 +635,104 @@ public class GameProgress : MonoBehaviour
get { return Army.SoldiersAtHome; }
}
public bool TroopUnlocked(int index) { return TownHallLevel >= GameData.Troops[index].requiredTownHall; }
// Тип войск открыт, если выполнены оба условия: уровень Ратуши (GameData.Troops) и уровень Казарм
// (EconomyConfig, раздел "Солдат", ТЕСТ). Считаются самые прокачанные Казармы.
public bool TroopUnlocked(int index)
{
return TownHallLevel >= GameData.Troops[index].requiredTownHall && Barracks.MaxLevel >= RequiredBarracksLevel(index);
}
public int RequiredBarracksLevel(int index)
{
var table = EconomyConfig.Load().soldier.requiredBarracksLevel;
return table != null && index >= 0 && index < table.Length ? Mathf.Max(1, table[index]) : 1;
}
// Чего не хватает, чтобы открыть тип войск: "Ратуша 2 ур., Казармы 3 ур." (пусто -- открыт)
public string TroopLockText(int index)
{
var parts = new List<string>();
int th = GameData.Troops[index].requiredTownHall, bk = RequiredBarracksLevel(index);
if (TownHallLevel < th) parts.Add("Ратуша " + th + " ур.");
if (Barracks.MaxLevel < bk) parts.Add("Казармы " + bk + " ур.");
return string.Join(", ", parts.ToArray());
}
// ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 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 (!TroopUnlocked(index)) { UIKit.Toast("Нужна Ратуша " + def.requiredTownHall + " уровня"); return; }
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; }
// Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением
if (!Army.CanQueueTraining(index, count, Barracks.QueueSize, out string error)) { UIKit.Toast(error); return; }
if (!Pay(def.cost, count)) return;
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 });
long durationMs = (long)(def.secondsPerUnit * 1000.0 * count);
if (!Army.TryQueueTraining(index, count, durationMs, Barracks.QueueSize, GameClock.UnixNowMs, out error))
{
// Заказ не встал -- цену возвращаем целиком
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;
}
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);
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();
}
// Потери в бою: убираем процент от каждого типа войск
+2 -1
View File
@@ -24,6 +24,7 @@ public class TrainingOrder
public int count;
public long startTicks;
public long endTicks;
public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали
}
[Serializable]
@@ -96,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;
+182 -39
View File
@@ -4,7 +4,8 @@ using UnityEngine.UI;
using TMPro;
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
// ускорение за кристаллы. Новые типы войск открываются уровнем Ратуши.
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
public class TroopsPanel : BasePanel
{
readonly List<Image> queueBars = new List<Image>();
@@ -24,10 +25,46 @@ public class TroopsPanel : BasePanel
ProfilePanel.Header(army, "Армия");
ProfilePanel.KV(army, "Всего воинов в городе", GP.TotalTroops.ToString("N0"));
ProfilePanel.KV(army, "В походе на карте мира", GameServices.Get<IArmy>().TroopsInMarches.ToString("N0"));
if (GameServices.TryGet(out IPopulation population))
{
// Воины берутся из свободных жителей (модуль Населения), резерв не нанять
ProfilePanel.KV(army, "Обучаются в Казармах", population.InTraining.ToString("N0"));
ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") +
" (резерв " + population.CivilianReserve.ToString("N0") + ")");
}
// Раненые (решения 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);
// --- Гарнизон: часть воинов охраняет город и в походы не уходит ---
IArmy troopsArmy = GameServices.Get<IArmy>();
var garrison = UIKit.Card(list, UIKit.Theme.CardDark);
ProfilePanel.Header(garrison, "Гарнизон города");
ProfilePanel.KV(garrison, "В гарнизоне", troopsArmy.GarrisonTotal.ToString("N0") + " из " + troopsArmy.SoldiersAtHome.ToString("N0"));
ProfilePanel.KV(garrison, "Свободны для походов", troopsArmy.AvailableForMarch.ToString("N0"));
UIKit.Text(garrison, "Гарнизон защищает город и не уходит в походы. Воины в нём не пропадают: всего воинов столько же.", 22, UIKit.Theme.TextDim);
var gRow = UIKit.Row(garrison, 80, 10);
GarrisonButton(gRow, troopsArmy, "0", 0);
GarrisonButton(gRow, troopsArmy, "-10", troopsArmy.GarrisonTotal - 10);
GarrisonButton(gRow, troopsArmy, "-1", troopsArmy.GarrisonTotal - 1);
GarrisonButton(gRow, troopsArmy, "+1", troopsArmy.GarrisonTotal + 1);
GarrisonButton(gRow, troopsArmy, "+10", troopsArmy.GarrisonTotal + 10);
GarrisonButton(gRow, troopsArmy, "Все", troopsArmy.SoldiersAtHome);
// --- Нет казарм -- нанимать негде ---
if (Barracks.Count == 0)
{
@@ -42,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);
@@ -64,49 +102,154 @@ 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, "Откроется на Ратуше " + def.requiredTownHall + " уровня", 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;
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 сообщает об изменении
static void GarrisonButton(Transform row, IArmy army, string label, int target)
{
target = Mathf.Clamp(target, 0, army.SoldiersAtHome);
bool can = target != army.GarrisonTotal;
var b = UIKit.Button(row, label, can ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => { if (can) army.SetGarrison(target); }, -1, 80, 28);
UIKit.Size(b, -1, 80, 1);
}
protected override void Tick()
{
var d = GP.Data;
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);
}
}
+12
View File
@@ -64,11 +64,23 @@ public abstract class BasePanel : MonoBehaviour
t = UIKit.Stretch(UIKit.Make("Content", transform), 24, 130, 24, 24);
}
content = (RectTransform)t;
WrapInSafeArea(content);
}
return content;
}
}
// Фон окна -- на весь экран, а содержимое -- в безопасной зоне: на iPhone левый край уходил под вырез камеры.
// Content переезжает в растянутый контейнер с SafeArea и сохраняет свои отступы.
void WrapInSafeArea(RectTransform rt)
{
if (rt.parent != transform) return; // уже обёрнут
var safe = UIKit.Stretch(UIKit.Make("SafeArea", transform));
safe.SetSiblingIndex(rt.GetSiblingIndex());
safe.gameObject.AddComponent<SafeArea>().fitInsideParent = true; // окно -- не на весь экран
rt.SetParent(safe, false);
}
void Rebuild()
{
float pos = (scroll != null && !resetScroll) ? scroll.verticalNormalizedPosition : 1f;
@@ -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
+410 -12
View File
@@ -8,6 +8,27 @@ 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
@@ -19,8 +40,32 @@ public sealed class ArmyService : IArmy, ISaveParticipant
}
private readonly int[] troops = new int[Types];
private readonly int[] garrison = new int[Types];
private readonly List<MarchUnits> marches = new List<MarchUnits>();
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; }
@@ -28,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;
@@ -48,6 +107,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant
}
}
public int GarrisonTotal
{
get { int n = 0; foreach (var g in garrison) n += g; return n; }
}
public int GetGarrison(int type)
{
return type >= 0 && type < Types ? garrison[type] : 0;
}
public int AvailableForMarch => SoldiersAtHome - GarrisonTotal;
// Гарнизон набирается по составу армии в городе: 30 пехоты и 10 лучников, гарнизон 8 -> 6 пехоты и 2 лучника
public int SetGarrison(int count)
{
count = Math.Max(0, Math.Min(count, SoldiersAtHome));
int[] units = count > 0 ? ArmyMath.TakeProportional(troops, count) : null;
for (int i = 0; i < Types; i++) garrison[i] = units != null ? units[i] : 0;
Changed();
return count;
}
public void AddTroops(int type, int count)
{
if (type < 0 || type >= Types || count <= 0) return;
@@ -67,41 +148,275 @@ public sealed class ArmyService : IArmy, ISaveParticipant
troops[i] -= l;
lost += l;
}
ClampGarrison();
if (lost > 0) Changed();
return lost;
}
public bool TakeForMarch(int marchId, int count)
{
int[] taken = ArmyMath.TakeProportional(troops, count);
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
if (marches.Exists(m => m.marchId == marchId)) return false;
// Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет
if (marches.Count >= MarchSlots) return false;
// В поход -- только свободные воины, гарнизон остаётся в городе
var free = new int[Types];
for (int i = 0; i < Types; i++) free[i] = troops[i] - garrison[i];
int[] taken = ArmyMath.TakeProportional(free, count);
if (taken == null) return false;
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
marches.RemoveAll(m => m.marchId == marchId);
marches.Add(new MarchUnits { marchId = marchId, units = taken });
Changed();
return true;
}
public void ReturnFromMarch(int marchId, int survivors)
// Воины возвращаются только из известного похода, и только один раз: запись похода удаляется.
// Неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого -- иначе солдаты
// появлялись бы из ниоткуда. Перенос старых сохранений -- отдельно, в ImportLegacy.
public bool ReturnFromMarch(int marchId, int survivors)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null)
if (entry == null) return false;
int sent = 0;
foreach (var u in entry.units) sent += u;
int[] back = ArmyMath.SplitSurvivors(entry.units, Math.Min(survivors, sent)); // больше, чем ушло, не вернётся
for (int i = 0; i < Types; i++) troops[i] += back[i];
marches.Remove(entry);
Changed();
return true;
}
public int[] GetMarchUnits(int marchId)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
return entry != null ? (int[])entry.units.Clone() : null;
}
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
public bool ReturnUnits(int marchId, int[] survivorUnits)
{
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;
bool anyWounded = false;
if (woundedUnits != null) ProcessHealing(nowMs); // сначала освободить места, которые освободились раньше
for (int i = 0; i < Types; i++)
{
troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой
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;
}
else
marches.Remove(entry);
if (anyWounded) FillBeds(nowMs);
Changed();
return true;
}
// ---------------- Раненые и лечение ----------------
public int WoundedTotal
{
get
{
int[] back = ArmyMath.SplitSurvivors(entry.units, survivors);
for (int i = 0; i < Types; i++) troops[i] += back[i];
marches.Remove(entry);
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]));
}
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal });
}
// ---------------- Перенос из старого progress.json ----------------
@@ -112,12 +427,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant
{
if (legacyTroops != null)
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
Array.Clear(garrison, 0, Types);
marches.Clear();
if (legacyMarches != null)
{
foreach (var m in legacyMarches)
{
if (marches.Exists(x => x.marchId == m.Key)) continue; // один поход -- одна запись
var units = new int[Types];
for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]);
marches.Add(new MarchUnits { marchId = m.Key, units = units });
@@ -129,7 +446,13 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// ---------------- Сохранение (секция "army") ----------------
// Модуль без Unity, поэтому формат -- простые строки:
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
// train=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
// trainNext=<номер следующего заказа>
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
// healNext=<номер следующей группы лечения>
public string SaveKey => "army";
@@ -137,15 +460,36 @@ public sealed class ArmyService : IArmy, ISaveParticipant
{
var sb = new StringBuilder();
sb.Append("troops=").Append(Join(troops)).Append('\n');
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
foreach (var m in marches)
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
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();
}
public void Restore(string data)
{
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))
@@ -158,25 +502,79 @@ public sealed class ArmyService : IArmy, ISaveParticipant
int[] t = Parse(line.Substring("troops=".Length));
Array.Copy(t, troops, Types);
}
else if (line.StartsWith("garrison="))
{
int[] g = Parse(line.Substring("garrison=".Length));
Array.Copy(g, garrison, Types);
}
else if (line.StartsWith("march="))
{
string[] parts = line.Substring("march=".Length).Split(':');
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id))
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
}
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(); // гарнизон не больше войск, даже если файл правили руками
Changed();
}
// Новая игра: войск нет, походов нет
// Новая игра: войск нет, гарнизона и походов нет
public void ResetState()
{
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];
@@ -0,0 +1,48 @@
// ПРАВИЛА БОЯ: все числа боевой системы в одном месте. Документ: Docs/Modules/Battle.md
// Без Unity. В игре значения приходят из таблицы App/BattleConfig (Assets/Resources/BattleConfig.asset),
// App/GameModules переводит её в BattleRules. Без таблицы (тесты, сервер) -- значения по умолчанию ниже.
//
// Числа -- стартовые для тестирования (план от 27.10.2026). Изменил правила -- подними Version:
// итог боя запоминает версию, по которой посчитан.
public sealed class BattleRules
{
public int Version = 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 };
public int CounterPercent = 25; // контра: пехота > кавалерия > лучники > пехота
public int WallsPercent = 20; // защитникам за стенами
public int SiegeVsWallsPercent = 50; // осадным при штурме стен
public int SiegeInFieldPercent = -25; // осадным в поле и в обороне
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%
public static BattleRules Default => new BattleRules();
// Без случайности: для тестов, где важен точный итог
public static BattleRules NoRandom => new BattleRules { RandomPercent = 0 };
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a836fd9caa34954b185ca38b4d89afb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+383 -11
View File
@@ -1,26 +1,398 @@
using System;
using System.Numerics;
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
//
// Правила пока самые простые: побеждает тот, у кого больше солдат.
// Потом сюда добавятся типы войск, герой, технологии -- остальные модули менять не придётся.
// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026):
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус)
// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача);
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
// бонус типа против врага взвешивается по составу врага;
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12);
// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12);
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
// - потери (№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;
public BattleSystem() : this(BattleRules.Default) { }
public BattleSystem(BattleRules rules)
{
this.rules = rules ?? BattleRules.Default;
power = new int[TroopType.Count];
var defaults = BattleRules.Default.UnitPower;
for (int i = 0; i < TroopType.Count; i++)
power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
? this.rules.UnitPower[i] : defaults[i];
// Кривая из таблицы, если она корректна, иначе -- значения по умолчанию (ошибка в 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, в процентах
public int Counter(int attackerType, int defenderType)
{
if (attackerType == TroopType.Infantry && defenderType == TroopType.Cavalry) return rules.CounterPercent;
if (attackerType == TroopType.Cavalry && defenderType == TroopType.Archers) return rules.CounterPercent;
if (attackerType == TroopType.Archers && defenderType == TroopType.Infantry) return rules.CounterPercent;
return 0;
}
public BattleOutcome Resolve(BattleRequest request)
{
int attackers = request.Attackers;
int defenders = request.Defenders;
bool victory = attackers > defenders; // ничья -- в пользу защитника
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2), Rounding.Roll);
}
// Проигравший теряет половину своего войска, победитель -- столько же
int loserArmy = victory ? defenders : attackers;
int losses = (loserArmy + 1) / 2;
// Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается.
public BattleForecast Forecast(BattleRequest request)
{
int r = Math.Max(0, rules.RandomPercent * 10);
var f = new BattleForecast
{
AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue,
AttackerLossesMin = int.MaxValue
};
foreach (int attLuck in new[] { -r, r })
foreach (int defLuck in new[] { -r, r })
{
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);
}
// Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее
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, Rounding rounding)
{
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);
bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника
// Потери по кривой (№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);
// Выбывшие -- погибшие и раненые (решения 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];
var defOut = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attAlive[i] = att[i] - attDead[i] - attWounded[i];
defAlive[i] = def[i] - defDead[i] - defWounded[i];
defOut[i] = defDead[i] + defWounded[i];
}
return new BattleOutcome
{
Victory = victory,
AttackerLosses = losses,
DefenderLosses = losses,
Survivors = attackers - losses
BattleId = request.Seed,
RulesVersion = rules.Version,
AttackerLosses = Sum(attDead) + Sum(attWounded),
DefenderLosses = Sum(defOut),
Survivors = Sum(attAlive),
AttackerPower = attPower.Floor, // округление -- только для показа
DefenderPower = defPower.Floor,
AttackerLuckPermille = attLuckPermille,
DefenderLuckPermille = defLuckPermille,
AttackerConditionPercent = attCondition,
DefenderConditionPercent = defCondition,
AttackerUnitsBefore = att,
DefenderUnitsBefore = def,
AttackerSurvivorUnits = attAlive,
DefenderSurvivorUnits = defAlive,
AttackerDeadUnits = attDead,
DefenderDeadUnits = defDead,
AttackerWoundedUnits = attWounded,
DefenderWoundedUnits = defWounded,
DefenderLossUnits = defOut
};
}
// Силы сторон без удачи (точные дроби) и состав сторон
void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def,
out int attCondition, out int defCondition)
{
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);
}
// Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 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);
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;
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;
// воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e
BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum);
if (term > 0) raw += term;
}
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
};
}
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 = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side);
return (int)(x % (ulong)(2 * range + 1)) - range;
}
// Отдельный поток случайности для округления потерь (№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;
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];
if (units != null)
{
for (int i = 0; i < TroopType.Count && i < units.Length; i++) result[i] = Math.Max(0, units[i]);
}
else
{
result[TroopType.Infantry] = Math.Max(0, count); // состав неизвестен -- считаем пехотой
}
return result;
}
static int Sum(int[] values)
{
int n = 0;
foreach (var v in values) n += v;
return n;
}
}
@@ -6,6 +6,10 @@ using UnityEngine.EventSystems;
// не собирал ресурсы со здания под панелью, не улучшал Ратушу, не двигал камеру.
public static class UIBlocker
{
// Палец сейчас занят миром (например, игрок тащит призрак здания в режиме стройки) --
// камера в это время не двигается. Ставит и снимает тот, кто захватил ввод.
public static bool WorldInputCaptured;
public static bool IsPointerOverUI()
{
if (EventSystem.current == null) return false;
@@ -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
{
@@ -46,14 +58,14 @@ public interface ILeveledBuilding
int Level { get; set; }
}
// ---------------- Население: жители, еда, прирост ----------------
// ---------------- Население: жители, рабочие, еда, голод, прирост, найм ----------------
public interface IPopulation
{
int Total { get; } // все люди города: жители + воины (в городе и в походах)
int Total { get; } // все люди города: жители + обучаются в Казармах + воины (в городе и в походах)
int Civilians { get; } // жители (не воины)
int Workers { get; } // жители на работе (все профессии вместе)
int Idle { get; } // остальные жители: Civilians - Workers
int Workforce { get; } // сколько жителей вообще могут работать (взрослые; стартовый город -- 120)
int Workforce { get; } // сколько жителей могут работать: все жители (общего ограничения нет, регламент 31.10)
int FreeWorkers { get; } // могут работать, но без работы: Workforce - Workers
int Soldiers { get; } // воины -- их хранит модуль Армии
int HousingCap { get; } // предел населения: выше него прирост не идёт
@@ -61,6 +73,20 @@ public interface IPopulation
float FoodPerHour { get; } // сколько город съедает в час
float GrowthPerHour { get; } // прирост в час сейчас (0 -- если голод или упёрлись в предел)
bool IsStarving { get; } // еды не хватило на последний расчёт
// Голодал ли город в момент unixTime (секунды). Для боёв, прошедших, пока игра была закрыта:
// история хранится за последние MaxOfflineHours (48 ч), точность -- 10 минут (шаг расчёта).
bool WasStarvingAt(long unixTime);
// Найм воинов (решение команды от 19.10.2026): воины берутся из свободных жителей, гражданский резерв не отдаётся.
int CivilianReserve { get; } // гражданский резерв: столько свободных жителей в войско не взять
int Recruitable { get; } // сколько воинов можно нанять прямо сейчас: (свободные - резерв) / жителей на воина
int InTraining { get; } // людей, взятых из жителей и ещё обучающихся в Казармах
bool TryRecruit(int soldiers); // забрать людей в Казармы (в начале обучения); false -- столько жителей нет
void RecruitsTrained(int soldiers); // обучение закончено: этих людей теперь считает Армия (без проверки Армии)
// Обучение закончено -- передать новобранцев в Армию одним действием: люди уходят из Населения, только если
// Армия их действительно приняла (IArmy.AddTroops). false -- ничего не изменилось: столько новобранцев нет,
// Армия не подключена или не приняла (новобранцы остаются в Казармах, никто не теряется и не дублируется).
bool CompleteTraining(int troopType, int soldiers);
// Досчитать население до момента unixNow (вызывается раз в несколько секунд и при перемотке)
void Tick(long unixNow);
@@ -72,20 +98,93 @@ 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; } // типов войск (пехота, лучники, кавалерия, осадные)
int GetTroops(int type); // воинов этого типа в городе
int SoldiersAtHome { get; } // всего воинов в городе
int SoldiersAtHome { get; } // всего воинов в городе (вместе с гарнизоном)
int TroopsInMarches { get; } // воинов в походах
// Гарнизон -- часть воинов в городе: защищает город и в походы не уходит. Всего воинов от этого не меняется.
int GarrisonTotal { get; }
int GetGarrison(int type);
int AvailableForMarch { get; } // можно отправить в поход: в городе минус гарнизон
int SetGarrison(int count); // назначить гарнизон (по составу армии); возвращает, сколько назначено
void AddTroops(int type, int count); // обучены / вернулись
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
// Забрать воинов из города в поход (запоминает состав похода)
// Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно.
// База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены.
int MarchSlots { get; }
int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот)
// Забрать свободных воинов в поход (запоминает состав похода).
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
bool TakeForMarch(int marchId, int count);
// Вернуть выживших из похода по тем же типам войск
void ReturnFromMarch(int marchId, int survivors);
// Вернуть выживших из похода по тем же типам войск.
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
bool ReturnFromMarch(int marchId, int survivors);
// Состав похода по типам (копия); null -- такого похода нет
int[] GetMarchUnits(int marchId);
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
bool ReturnUnits(int marchId, int[] survivorUnits);
// ---- Раненые и лечение (решения по 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);
}
// ---------------- Глобальный мир: карта и перемещения ----------------
@@ -93,26 +192,149 @@ 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); // досчитать наблюдение до момента (в том числе офлайн)
}
// ---------------- Боевая система: урон, потери, исход ----------------
public struct BattleRequest
// Типы войск в бою -- порядок как в GameData.Troops и в модуле Армии
public static class TroopType
{
public int Attackers;
public int Defenders;
public const int Infantry = 0; // пехота
public const int Archers = 1; // лучники
public const int Cavalry = 2; // кавалерия
public const int Siege = 3; // осадные
public const int Count = 4;
}
// Состояние войск стороны в бою (план от 27.10.2026). Штрафы применяются последовательно:
// голод (-50%) и усталость (-20%) вместе оставляют 40% силы, а не 30%. Числа -- в правилах боя.
[Flags]
public enum BattleCondition
{
None = 0,
Famine = 1, // голод в поселении на момент боя
Fatigue = 2 // войско недавно сражалось (пока не выставляется: правила учёта усталости не утверждены)
}
public struct BattleRequest
{
// Состав сторон по типам войск (TroopType). Если состав не задан (null) --
// берётся число Attackers / Defenders, и все считаются пехотой.
public int[] AttackerUnits;
public int[] DefenderUnits;
public int Attackers;
public int Defenders;
public bool DefenderFortified; // защитники за стенами (город, замок)
public int AttackerBonusPercent; // герой, технологии, голод (минус) -- +10 = на 10% сильнее
public int DefenderBonusPercent;
// Состояния сторон: голод, усталость. Действуют последовательно (умножаются), см. BattleCondition
public BattleCondition AttackerConditions;
public BattleCondition DefenderConditions;
// Номер боя: из него считается случайность сторон (±5%). Один и тот же Seed с теми же войсками --
// всегда тот же итог, бой можно перепроверить. В мультиплеере Seed выдаёт сервер.
public long Seed;
}
// Итог боя -- одинаково полный для обеих сторон. Все массивы -- по типам войск (TroopType).
// По каждому типу: до боя = выжившие + погибшие + раненые.
public struct BattleOutcome
{
public bool Victory; // победил атакующий
public int AttackerLosses;
public long BattleId; // = Seed запроса
public int RulesVersion; // версия правил боя (баланса), по которой посчитан итог
public int AttackerLosses; // выбыли из строя: погибшие + раненые
public int DefenderLosses;
public int Survivors; // сколько атакующих осталось
public int Survivors; // сколько атакующих осталось в строю
public long AttackerPower; // итоговая сила сторон x100: с контрами, стенами, бонусами и случайностью
public long DefenderPower;
public int AttackerLuckPermille; // случайность стороны в десятых долях процента: +23 = +2,3%
public int DefenderLuckPermille;
public int AttackerConditionPercent; // сколько силы оставили состояния стороны: 100 -- без штрафов, 50 -- голод
public int DefenderConditionPercent;
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
public int[] AttackerDeadUnits, DefenderDeadUnits;
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7): выбывшие минус погибшие
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
}
// Прогноз до боя: с учётом случайности, без раскрытия итога (план от 27.10.2026)
public enum BattleChance
{
Advantage, // победа при любой случайности
Uncertain, // исход зависит от случайности
HighRisk // поражение при любой случайности
}
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; // с учётом округления потерь (решение №13)
}
public interface IBattleSystem
{
BattleOutcome Resolve(BattleRequest request);
BattleForecast Forecast(BattleRequest request); // Seed не нужен: перебираются крайние значения случайности
}
// ---------------- Чат: общий мир и Круг ----------------
@@ -32,6 +32,15 @@ public struct GameTimeAdvancedEvent
public long UnixNow;
}
// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11).
// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick.
// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг
// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна.
public struct GameTimeSimulatedEvent
{
public long UnixNow;
}
// ---------------- Население ----------------
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
public struct PopulationChangedEvent
@@ -61,19 +70,45 @@ 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;
@@ -83,9 +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 IsGather; // поход на ресурсную точку (сбор, а не бой)
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
}
@@ -121,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)
{
@@ -12,13 +12,18 @@ public sealed class ProductionRules
public int SlotsPerBuilding = 30; // мест в здании 1 уровня (Лесопилка T1 -- 30)
public int SlotsPerLevel = 10; // + мест за каждый уровень выше первого. Не утверждено
public double EfficiencyPerLevel = 0.1; // + к норме за каждый уровень выше первого (×1.1, ×1.2...). Не утверждено
// Голод (регламент 31.10, ТЕСТ): добыча всех ресурсов, кроме еды, ×0.5. Еда -- без штрафа.
// Штраф снимается сразу, как только город снова сыт (модуль Населения сообщает IsStarving).
public double FamineNonFoodMultiplier = 0.5;
}
// МОДУЛЬ ЭКОНОМИКИ: добыча через рабочих (этап 3). Документ: Docs/Modules/Economy.md
// Без Unity: правила те же, что будут на сервере.
//
// Ресурс производят люди, здание даёт только места и эффективность:
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий.
// добыча в час = min(рабочие, места) × норма × эффективность уровня зданий,
// а при голоде всё, кроме еды, -- ещё × FamineNonFoodMultiplier (голод считает модуль Населения, IPopulation.IsStarving).
// Какие здания стоят в городе, сообщает слой приложения (App/CitySync) -- модуль их не ищет.
// Места запоминаются в сохранении: на карте мира и офлайн город не открыт, а добыча идёт.
//
@@ -80,7 +85,10 @@ public sealed class ProductionService : IProduction, ISaveParticipant
int j = (int)job;
if (!GameServices.TryGet(out IPopulation population)) return 0;
int working = Math.Min(population.GetWorkers(job), slots[j]);
return working * rules.PerWorkerPerHour[j] * efficiency[j];
double rate = working * rules.PerWorkerPerHour[j] * efficiency[j];
if (population.IsStarving && WorkJobs.Resource(job) != ResourceType.Food)
rate *= Math.Max(0, Math.Min(1, rules.FamineNonFoodMultiplier));
return rate;
}
public void Produce(double hours)
+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
@@ -1,3 +1,4 @@
// МОДУЛЬ НАСЕЛЕНИЯ. Документ: Docs/Modules/Population.md
// Правила населения -- только числа, без Unity. Заполняются из EconomyConfig (App/GameModules),
// в тестах -- напрямую. Значения по умолчанию -- из дизайн-документа (этап 2 плана разработки).
public sealed class PopulationRules
@@ -21,6 +22,12 @@ public sealed class PopulationRules
public int HousingPerTownHallLevel = 50;
public int HousingPerHouseLevel = 50; // каждый уровень Дома (здание "Население")
// Найм воинов: воины набираются из свободных (не работающих) жителей, гражданский резерв не отдаётся.
// Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1-2 -- 10, 3-5 -- 20, 6-10 -- 30.
// Элемент [i] -- резерв на уровне Ратуши i+1; выше последнего уровня -- последнее число.
public int[] CivilianReserveByTownHallLevel = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30 };
public int PeoplePerSoldier = 1; // сколько жителей стоит один воин (EconomyConfig, раздел "Солдат")
// Офлайн-прогресс досчитывается не больше чем за столько часов. Число НЕ утверждено.
public int MaxOfflineHours = 48;
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
@@ -7,7 +8,17 @@ using System.Text;
//
// Люди города = жители (этот модуль) + воины (модуль Армии). Воин -- тоже человек: он ест (0.2 еды в час)
// и входит в общее население, но хранится в Армии, чтобы один человек не был записан в двух местах.
// Жители: часть может работать (Workforce, стартовый город -- 120 из 270), из них рабочие стоят на профессиях.
// Жители: работать может любой житель (общего ограничения нет, регламент 31.10) -- рабочих ограничивают
// свободные жители и рабочие места в зданиях. Стартовый город: 120 рабочих из 270 жителей.
//
// Каждый человек ровно в одной группе (регламент 31.10):
// свободный житель = Civilians - Workers (Idle)
// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных)
// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает)
// воин = Soldiers (хранит Армия: в городе, в походах и раненые; RecruitsTrained убирает новобранцев до зачисления в Армию)
// Total = Civilians + InTraining + Soldiers -- сумма всех групп.
// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок.
// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять.
//
// Время -- только из GameClock. Расчёт идёт кусками по 10 минут: раз в несколько секунд в игре,
// при входе в игру (офлайн-прогресс) и при перемотке в отладочной панели -- правила одни и те же.
@@ -28,6 +39,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
double foodOwed; // съедено, но ещё не списано со склада (меньше 1 еды)
long lastTickUnix; // до какого момента население уже посчитано; 0 -- ещё ни разу
bool starving;
int inTraining; // люди, взятые из жителей в Казармы и ещё не обученные
// История голода: отрезки [начало, конец) в Unix-секундах, конец 0 -- голод идёт сейчас.
// Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод считается на момент боя.
readonly List<long[]> famine = new List<long[]>();
bool startingKitGiven; // стартовое войско и ресурсы новой игры уже выданы (или это старое сохранение)
public PopulationService(PopulationRules rules)
@@ -53,16 +68,9 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
public int Idle => Math.Max(0, civilians - Workers);
// Доля жителей, способных работать: 120 из 270 в стартовом городе
public int Workforce
{
get
{
int startCiv = Math.Max(1, rules.StartCivilians);
double share = Math.Min(1.0, rules.StartWorkers / (double)startCiv);
return (int)Math.Floor(civilians * share + Epsilon);
}
}
// Работать может любой житель: общего ограничения нет (регламент 31.10).
// Рабочих ограничивают свободные жители и рабочие места (IProduction.Slots).
public int Workforce => civilians;
public int FreeWorkers => Math.Max(0, Workforce - Workers);
@@ -71,11 +79,69 @@ 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)
}
}
public int Total => civilians + Soldiers;
public int Total => civilians + inTraining + Soldiers;
// ---------------- Найм воинов ----------------
public int InTraining => inTraining;
public int CivilianReserve
{
get
{
int level = GameServices.TryGet(out ISettlement settlement) ? Math.Max(1, settlement.TownHallLevel) : 1;
int[] table = rules.CivilianReserveByTownHallLevel;
if (table == null || table.Length == 0) return 0;
return Math.Max(0, table[Math.Min(level, table.Length) - 1]);
}
}
// Берём только свободных (Idle -- не на работе): рабочих сначала надо снять с профессии
public int Recruitable => Math.Max(0, Idle - CivilianReserve) / Math.Max(1, rules.PeoplePerSoldier);
public bool TryRecruit(int soldiers)
{
if (soldiers <= 0 || soldiers > Recruitable) return false;
int people = soldiers * Math.Max(1, rules.PeoplePerSoldier);
civilians -= people;
inTraining += people;
ClampJobs(); // жителей меньше -- рабочих тоже может стать меньше
Changed();
return true;
}
public bool CompleteTraining(int troopType, int soldiers)
{
if (soldiers <= 0) return false;
int people = soldiers * Math.Max(1, rules.PeoplePerSoldier);
if (people > inTraining) return false; // столько новобранцев нет (повтор или ошибка)
if (!GameServices.TryGet(out IArmy army)) return false;
// Сначала убираем из Казарм, потом отдаём в Армию: событие Армии увидит уже правильную сумму.
// Армия не приняла -- возвращаем новобранцев, как было.
inTraining -= people;
int before = army.SoldiersAtHome;
army.AddTroops(troopType, soldiers);
if (army.SoldiersAtHome - before != soldiers)
{
inTraining += people;
return false;
}
Changed();
return true;
}
public void RecruitsTrained(int soldiers)
{
if (soldiers <= 0) return;
// Заказы, начатые до найма из жителей (старое сохранение), людей не брали -- уходить в минус нельзя
inTraining = Math.Max(0, inTraining - soldiers * Math.Max(1, rules.PeoplePerSoldier));
Changed();
}
public int HousingCap
{
@@ -90,9 +156,33 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
public float GrowthPerHour => starving || Total >= HousingCap ? 0f : (float)rules.GrowthPerHour;
public bool IsStarving => starving;
public bool WasStarvingAt(long unixTime)
{
if (lastTickUnix == 0 || unixTime >= lastTickUnix) return starving; // ещё не посчитано -- как сейчас
foreach (var f in famine)
if (unixTime >= f[0] && (f[1] == 0 || unixTime < f[1])) return true;
return false;
}
// Голод начался или кончился в момент unix (вызывается из расчёта на каждом шаге)
void RecordFamine(bool nowStarving, long unix)
{
long[] open = famine.Count > 0 && famine[famine.Count - 1][1] == 0 ? famine[famine.Count - 1] : null;
if (nowStarving && open == null) famine.Add(new[] { unix, 0L });
else if (!nowStarving && open != null) open[1] = unix;
}
// Отрезки, закончившиеся раньше, чем 48 часов назад, Бою уже не понадобятся
void PruneFamine(long unixNow)
{
long keepFrom = unixNow - (long)rules.MaxOfflineHours * SecondsPerHour;
famine.RemoveAll(f => f[1] != 0 && f[1] <= keepFrom);
}
double FoodPerHourExact(int soldiers)
{
return civilians * rules.FoodPerCivilianPerHour + soldiers * rules.FoodPerSoldierPerHour;
// Обучающиеся в Казармах едят как воины
return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour;
}
public int GetWorkers(WorkJob job)
@@ -106,7 +196,7 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
int j = (int)job;
if (j < 0 || j >= WorkJobs.Count) return 0;
int limit = jobs[j] + FreeWorkers; // больше, чем есть свободных, не поставить
int limit = jobs[j] + FreeWorkers; // только свободные жители: не воины, не новобранцы, не уже работающие
int slots = SlotsFor(job);
if (slots >= 0) limit = Math.Min(limit, slots); // и больше, чем мест в зданиях
int value = Math.Max(0, Math.Min(count, limit));
@@ -131,28 +221,37 @@ 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;
Simulate(elapsed);
Simulate(unixNow - elapsed, elapsed);
lastTickUnix = unixNow;
PruneFamine(unixNow);
SimulatedUntil(unixNow);
}
// Расчёт дошёл до момента: то, что случается по времени (стройка закончилась), применяется сейчас (GameTimeSimulatedEvent)
static void SimulatedUntil(long unixNow)
{
EventBus.Publish(new GameTimeSimulatedEvent { UnixNow = unixNow });
}
// ---------------- Расчёт ----------------
void Simulate(long seconds)
void Simulate(long startUnix, long seconds)
{
if (!GameServices.TryGet(out IEconomy economy)) return; // без склада считать нечего
GameServices.TryGet(out IProduction production);
@@ -162,12 +261,17 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
int soldiers = Soldiers;
long remaining = seconds;
long stepStart = startUnix;
while (remaining > 0)
{
long step = Math.Min(remaining, StepSeconds);
remaining -= step;
double hours = step / (double)SecondsPerHour;
// Расчёт дошёл до начала шага: стройки, закончившиеся к этому моменту, вступают в силу,
// и добыча этого шага считается уже с ними (а не с начала офлайна)
SimulatedUntil(stepStart);
// 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест
production?.Produce(hours);
@@ -195,11 +299,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
fed = false;
}
starving = !fed;
RecordFamine(starving, stepStart); // голод начался (кончился) в этом куске -- запоминаем его начало
stepStart += step;
// 2. Прирост -- только сытым и только до предела жилья. Что ещё бывает при голоде -- решит команда.
if (fed)
{
int room = HousingCap - (civilians + soldiers);
int room = HousingCap - (civilians + inTraining + soldiers);
if (room > 0)
{
growthProgress += rules.GrowthPerHour * hours;
@@ -223,25 +329,19 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
// ---------------- Рабочие: расстановка ----------------
// Поставить свободных рабочих на пустые места профессии (новое здание, улучшение). Возвращает, сколько поставлено.
public int AutoFill(WorkJob job)
{
int slots = SlotsFor(job);
int room = slots < 0 ? FreeWorkers : Math.Min(FreeWorkers, slots - jobs[(int)job]);
if (room <= 0) return 0;
SetWorkers(job, jobs[(int)job] + room);
return room;
}
// Расставить свободных по всем профессиям поровну (старое сохранение, первый запуск после этапа 3)
// Стартовая расстановка -- единственное автоматическое заполнение мест (регламент 31.10).
// Один раз: в новой игре или при первой загрузке старого сохранения (пока JobsInitialized == false).
// Свободных -- поровну по всем местам, всего не больше StartWorkers (120). Новые места после стройки
// и улучшения остаются пустыми, пока игрок сам не поставит рабочих.
public void AutoFillAll()
{
bool changed = false;
bool progress = true;
while (FreeWorkers > 0 && progress)
int cap = Math.Max(Workers, rules.StartWorkers);
while (FreeWorkers > 0 && Workers < cap && progress)
{
progress = false;
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0; j++)
for (int j = 0; j < WorkJobs.Count && FreeWorkers > 0 && Workers < cap; j++)
{
int slots = SlotsFor((WorkJob)j);
if (slots >= 0 && jobs[j] >= slots) continue;
@@ -327,6 +427,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
foodOwed = 0;
lastTickUnix = 0;
starving = false;
inTraining = 0;
famine.Clear();
startingKitGiven = false;
}
@@ -348,6 +450,12 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
sb.Append("food=").Append(foodOwed.ToString("R", CultureInfo.InvariantCulture)).Append('\n');
sb.Append("lastTick=").Append(lastTickUnix.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("starving=").Append(starving ? "1" : "0").Append('\n');
sb.Append("training=").Append(inTraining.ToString(CultureInfo.InvariantCulture)).Append('\n');
// famine=начало-конец;начало-конец (конец 0 -- голод идёт сейчас)
var spans = new List<string>();
foreach (var f in famine)
spans.Add(f[0].ToString(CultureInfo.InvariantCulture) + "-" + f[1].ToString(CultureInfo.InvariantCulture));
sb.Append("famine=").Append(string.Join(";", spans.ToArray())).Append('\n');
sb.Append("kit=").Append(startingKitGiven ? "1" : "0").Append('\n');
return sb.ToString();
}
@@ -385,9 +493,30 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long t)) lastTickUnix = Math.Max(0, t);
break;
case "starving": starving = value == "1"; break;
case "training": inTraining = Math.Max(0, ParseInt(value, 0)); break;
case "famine":
foreach (var span in value.Split(';'))
{
string[] se = span.Split('-');
if (se.Length == 2
&& long.TryParse(se[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fs)
&& long.TryParse(se[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out long fe))
famine.Add(new[] { fs, fe });
}
break;
case "kit": startingKitGiven = value == "1"; break;
}
}
// Рабочих не может быть больше, чем жителей (испорченное или очень старое сохранение).
// Места в зданиях здесь не проверяем: секция Экономики может загрузиться позже -- это делает ClampJobs.
int over = Workers - civilians;
for (int j = WorkJobs.Count - 1; j >= 0 && over > 0; j--)
{
int take = Math.Min(over, jobs[j]);
jobs[j] -= take;
over -= take;
}
}
// Новая игра: стартовый город. Стартовое войско выдаст App/GameModules после сброса всех модулей.
@@ -0,0 +1,115 @@
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Призрак здания в режиме стройки: полупрозрачная копия картинки здания и пятно его клеток под ней.
// Пятно зелёное -- сюда можно ставить, красное -- клетки заняты или за границей.
// Только картинка: скриптов и коллайдеров префаба у призрака нет, экономика его не видит.
public class BuildGhost : MonoBehaviour
{
public BuildingData Data { get; private set; }
public BuildFootprint Footprint { get; private set; }
public Vector2Int Cell { get; private set; } // левая нижняя клетка на сетке
public bool CellsFree { get; private set; }
public static readonly Color FreeColor = new Color(0.25f, 1f, 0.35f, 0.45f);
public static readonly Color BlockedColor = new Color(1f, 0.2f, 0.2f, 0.5f);
SpriteRenderer pad;
static Sprite white;
public static BuildGhost Create(BuildingData data)
{
var go = new GameObject("BuildGhost_" + data.buildingName);
var g = go.AddComponent<BuildGhost>();
g.Data = data;
g.Footprint = BuildFootprint.Of(data);
g.Build();
return g;
}
void Build()
{
// пятно клеток: белый квадрат 1×1, растянутый на размер здания
var padGo = new GameObject("Cells");
padGo.transform.SetParent(transform, false);
pad = padGo.AddComponent<SpriteRenderer>();
pad.sprite = WhiteSprite();
pad.sortingOrder = 3;
padGo.transform.localScale = new Vector3(Footprint.size.x, Footprint.size.y, 1f);
// копия картинки здания: позиция центра пятна = позиция префаба + anchorOffset
var art = new GameObject("Art").transform;
art.SetParent(transform, false);
art.localPosition = -(Vector3)Footprint.anchorOffset;
var prefab = Data.prefab;
if (prefab == null) return;
Vector3 root = prefab.transform.position;
int minOrder = int.MaxValue;
var renderers = prefab.GetComponentsInChildren<SpriteRenderer>(true);
foreach (var sr in renderers) if (sr.sprite != null) minOrder = Mathf.Min(minOrder, sr.sortingOrder);
foreach (var sr in renderers)
{
if (sr.sprite == null) continue;
var copy = new GameObject(sr.name).transform;
copy.SetParent(art, false);
copy.localPosition = sr.transform.position - root;
copy.localRotation = sr.transform.rotation;
copy.localScale = sr.transform.lossyScale;
var r = copy.gameObject.AddComponent<SpriteRenderer>();
r.sprite = sr.sprite;
r.flipX = sr.flipX;
r.flipY = sr.flipY;
r.drawMode = sr.drawMode;
if (sr.drawMode != SpriteDrawMode.Simple) r.size = sr.size;
var c = sr.color; c.a *= 0.65f;
r.color = c;
r.sortingOrder = 4 + (sr.sortingOrder - minOrder);
}
}
static Sprite WhiteSprite()
{
if (white != null) return white;
var tex = new Texture2D(4, 4, TextureFormat.RGBA32, false);
var px = new Color32[16];
for (int i = 0; i < px.Length; i++) px[i] = new Color32(255, 255, 255, 255);
tex.SetPixels32(px);
tex.Apply();
white = Sprite.Create(tex, new Rect(0, 0, 4, 4), new Vector2(0.5f, 0.5f), 4);
return white;
}
// Поставить призрак на клетку (левая нижняя) и проверить, свободно ли
public void MoveToCell(Vector2Int cell)
{
var grid = BuildGrid.Instance;
Cell = cell;
Vector2 center = grid.RectCenter(cell, Footprint.size);
transform.position = new Vector3(center.x, center.y, 0f);
Recheck();
}
// Подвинуть так, чтобы центр пятна был ближе всего к точке мира
public void MoveNear(Vector2 worldCenter)
{
MoveToCell(BuildGrid.Instance.SnapCell(worldCenter, Footprint.size));
}
public void Recheck()
{
CellsFree = BuildGrid.Instance.CanPlace(Cell, Footprint.size);
if (pad != null) pad.color = CellsFree ? FreeColor : BlockedColor;
}
// Точка мира попадает в пятно призрака (с запасом под палец)
public bool Contains(Vector2 world, float margin = 0.4f)
{
Vector2 c = transform.position;
Vector2 half = (Vector2)Footprint.size * 0.5f + Vector2.one * margin;
return Mathf.Abs(world.x - c.x) <= half.x && Mathf.Abs(world.y - c.y) <= half.y;
}
// Куда ставить префаб, чтобы здание встало ровно на клетки призрака
public Vector3 PrefabPosition => transform.position - (Vector3)Footprint.anchorOffset;
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4342ee63e1ed441786fda6c36b2da960
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,372 @@
using System.Collections.Generic;
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Сетка свободной стройки: игрок сам выбирает, где поставить здание.
//
// - Клетка = 1 единица мира. Сетка прямоугольная (арт города не изометрический).
// - Где можно строить: прямоугольник вокруг старых участков (BuildSlot) и Ратуши с запасом padding.
// Можно задать руками: autoArea = false и areaMin / areaMax.
// - Занятые клетки: под каждым построенным зданием (его размер на сетке, BuildingData.footprint) и под Ратушей.
// Старые сохранения: здание стоит, где стояло, занятость считается по тому, какие клетки оно накрывает.
// - В режиме стройки сетка рисуется поверх земли: свободные клетки -- светлые линии, занятые -- красные.
// - Старые участки и площадки «Застройка» прячутся при запуске (свободная стройка их заменила).
//
// Объект создаётся сам (BuildGrid.Instance), руками на сцену ставить не нужно.
// Если поставить руками -- можно настроить поля в инспекторе.
public class BuildGrid : MonoBehaviour
{
static BuildGrid instance;
public static BuildGrid Instance
{
get
{
if (instance == null)
{
instance = FindFirstObjectByType<BuildGrid>();
if (instance == null) instance = new GameObject("BuildGrid").AddComponent<BuildGrid>();
}
return instance;
}
}
[Header("Где можно строить")]
[Tooltip("Посчитать область самому: старые участки + Ратуша + запас")]
public bool autoArea = true;
// Здание, которое сейчас переносят: его клетки считаются свободными (режим стройки, перенос)
public static GameObject IgnoredBuilding;
[Tooltip("Запас вокруг старых участков, клеток")]
public int padding = 3;
public Vector2 areaMin = new Vector2(-34, -10);
public Vector2 areaMax = new Vector2(4, 12);
[Header("Вид сетки")]
public int overlaySortingOrder = 1;
public Color lineColor = new Color(1f, 1f, 1f, 0.28f);
public Color freeColor = new Color(0.4f, 1f, 0.5f, 0.06f);
public Color busyColor = new Color(1f, 0.15f, 0.15f, 0.38f);
[Tooltip("Спрятать старые участки и площадки «Застройка» при запуске")]
public bool hideLegacySlots = true;
public Vector2Int Origin { get; private set; } // мировые координаты левого нижнего угла клетки (0, 0)
public int Width { get; private set; }
public int Height { get; private set; }
bool[,] busy;
bool initialized;
float nextRefresh;
SpriteRenderer overlay;
Texture2D overlayTex;
string lastOverlayKey;
const int PixelsPerCell = 16;
void Awake()
{
if (instance != null && instance != this) { Destroy(this); return; }
instance = this;
}
void OnDestroy()
{
if (instance == this) instance = null;
if (overlayTex != null) Destroy(overlayTex);
}
void Update()
{
if (overlay != null && overlay.enabled && Time.unscaledTime >= nextRefresh) Refresh();
}
// ---------------- область ----------------
void EnsureInit()
{
if (initialized) return;
initialized = true;
Rect area = autoArea ? AutoArea() : Rect.MinMaxRect(areaMin.x, areaMin.y, areaMax.x, areaMax.y);
Origin = new Vector2Int(Mathf.FloorToInt(area.xMin), Mathf.FloorToInt(area.yMin));
Width = Mathf.Max(1, Mathf.CeilToInt(area.xMax) - Origin.x);
Height = Mathf.Max(1, Mathf.CeilToInt(area.yMax) - Origin.y);
busy = new bool[Width, Height];
if (hideLegacySlots) HideLegacySlots();
Debug.Log($"[СТРОЙКА] Сетка {Width}×{Height} клеток, левый нижний угол {Origin}");
}
Rect AutoArea()
{
bool any = false;
Bounds b = new Bounds();
foreach (var slot in FindObjectsByType<BuildSlot>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
Bounds sb = new Bounds(slot.transform.position, Vector3.one);
var col = slot.GetComponent<Collider2D>();
if (col != null && col.enabled && col.gameObject.activeInHierarchy) sb.Encapsulate(col.bounds);
if (!any) { b = sb; any = true; } else b.Encapsulate(sb);
}
if (TownHall.Instance != null)
{
Bounds tb = TownHallBounds();
if (!any) { b = tb; b.Expand(new Vector3(24, 16, 0)); any = true; }
else b.Encapsulate(tb);
}
if (!any) b = new Bounds(Vector3.zero, new Vector3(30, 20, 0));
b.Expand(new Vector3(padding * 2, padding * 2, 0));
return Rect.MinMaxRect(b.min.x, b.min.y, b.max.x, b.max.y);
}
static Bounds TownHallBounds()
{
var th = TownHall.Instance;
var col = th.GetComponent<Collider2D>();
if (col != null && col.enabled) return col.bounds;
var sr = th.GetComponentInChildren<SpriteRenderer>();
return sr != null ? sr.bounds : new Bounds(th.transform.position, new Vector3(3, 3, 0));
}
static void HideLegacySlots()
{
foreach (var slot in FindObjectsByType<BuildSlot>(FindObjectsInactive.Include, FindObjectsSortMode.None))
{
HideOn(slot.gameObject);
var parent = slot.transform.parent;
if (parent != null && parent.name.StartsWith("Застройка")) HideOn(parent.gameObject);
}
}
// Прячем только сам объект (картинку и коллайдер), не детей -- и сцену не меняем
static void HideOn(GameObject go)
{
foreach (var sr in go.GetComponents<SpriteRenderer>()) sr.enabled = false;
foreach (var c in go.GetComponents<Collider2D>()) c.enabled = false;
}
// ---------------- клетки ----------------
public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
public bool IsBusy(int x, int y) { EnsureInit(); return !InBounds(x, y) || busy[x, y]; }
// Клетка мира -> индекс сетки
public Vector2Int WorldToCell(Vector2 world)
{
EnsureInit();
return new Vector2Int(Mathf.FloorToInt(world.x) - Origin.x, Mathf.FloorToInt(world.y) - Origin.y);
}
// Центр прямоугольника клеток (x, y, w, h) в мире
public Vector2 RectCenter(Vector2Int cell, Vector2Int size)
{
EnsureInit();
return new Vector2(Origin.x + cell.x + size.x * 0.5f, Origin.y + cell.y + size.y * 0.5f);
}
// Левая нижняя клетка для здания размера size, чей центр ближе всего к точке мира
public Vector2Int SnapCell(Vector2 worldCenter, Vector2Int size)
{
EnsureInit();
return new Vector2Int(Mathf.RoundToInt(worldCenter.x - Origin.x - size.x * 0.5f),
Mathf.RoundToInt(worldCenter.y - Origin.y - size.y * 0.5f));
}
// Можно ли поставить: все клетки внутри области и свободны
public bool CanPlace(Vector2Int cell, Vector2Int size)
{
EnsureInit();
for (int x = cell.x; x < cell.x + size.x; x++)
for (int y = cell.y; y < cell.y + size.y; y++)
if (!InBounds(x, y) || busy[x, y]) return false;
return true;
}
// Ближайшее свободное место для здания размера size рядом с точкой (по спирали). false -- места нет
public bool FindFreeNear(Vector2 worldCenter, Vector2Int size, out Vector2Int cell)
{
Refresh();
Vector2Int start = SnapCell(worldCenter, size);
int maxR = Mathf.Max(Width, Height);
for (int r = 0; r <= maxR; r++)
{
for (int dx = -r; dx <= r; dx++)
for (int dy = -r; dy <= r; dy++)
{
if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; // только кольцо радиуса r
var c = new Vector2Int(start.x + dx, start.y + dy);
if (CanPlace(c, size)) { cell = c; return true; }
}
}
cell = start;
return false;
}
// Пересчитать занятые клетки по тому, что стоит в городе сейчас
public void Refresh()
{
EnsureInit();
nextRefresh = Time.unscaledTime + 0.3f;
System.Array.Clear(busy, 0, busy.Length);
if (TownHall.Instance != null)
{
Bounds tb = TownHallBounds();
MarkRect(new Rect(tb.min.x, tb.min.y, tb.size.x, tb.size.y));
}
var bm = BuildManager.Instance;
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре
if (IgnoredBuilding != null && id.gameObject == IgnoredBuilding) continue; // его переносят
BuildingData data = bm != null ? bm.FindData(id.buildingName) : null;
Rect r;
if (data != null)
{
var fp = BuildFootprint.Of(data);
Vector2 center = (Vector2)id.transform.position + fp.anchorOffset;
r = new Rect(center.x - fp.size.x * 0.5f, center.y - fp.size.y * 0.5f, fp.size.x, fp.size.y);
}
else
{
var col = id.GetComponent<Collider2D>();
if (col == null) continue;
r = new Rect(col.bounds.min.x, col.bounds.min.y, col.bounds.size.x, col.bounds.size.y);
}
MarkRect(r);
}
if (overlay != null && overlay.enabled) RedrawOverlay();
}
// Отметить все клетки, которые прямоугольник мира накрывает (касание краем не считается)
void MarkRect(Rect r)
{
const float eps = 0.05f;
int x0 = Mathf.FloorToInt(r.xMin + eps) - Origin.x, x1 = Mathf.CeilToInt(r.xMax - eps) - Origin.x;
int y0 = Mathf.FloorToInt(r.yMin + eps) - Origin.y, y1 = Mathf.CeilToInt(r.yMax - eps) - Origin.y;
for (int x = Mathf.Max(0, x0); x < Mathf.Min(Width, x1); x++)
for (int y = Mathf.Max(0, y0); y < Mathf.Min(Height, y1); y++)
busy[x, y] = true;
}
// ---------------- рисунок сетки ----------------
public void ShowOverlay(bool show)
{
EnsureInit();
if (show && overlay == null) CreateOverlay();
if (overlay == null) return;
overlay.enabled = show;
if (show) Refresh();
}
void CreateOverlay()
{
overlayTex = new Texture2D(Width * PixelsPerCell, Height * PixelsPerCell, TextureFormat.RGBA32, false);
overlayTex.filterMode = FilterMode.Point;
overlayTex.wrapMode = TextureWrapMode.Clamp;
var sprite = Sprite.Create(overlayTex, new Rect(0, 0, overlayTex.width, overlayTex.height), Vector2.zero, PixelsPerCell);
var go = new GameObject("BuildGridOverlay");
go.transform.SetParent(transform, false);
go.transform.position = new Vector3(Origin.x, Origin.y, 0f);
overlay = go.AddComponent<SpriteRenderer>();
overlay.sprite = sprite;
overlay.sortingOrder = overlaySortingOrder;
lastOverlayKey = null;
}
void RedrawOverlay()
{
// перерисовываем только если занятость поменялась
var key = new System.Text.StringBuilder(Width * Height);
for (int y = 0; y < Height; y++) for (int x = 0; x < Width; x++) key.Append(busy[x, y] ? '1' : '0');
string k = key.ToString();
if (k == lastOverlayKey) return;
lastOverlayKey = k;
int w = overlayTex.width, h = overlayTex.height;
var px = new Color32[w * h];
Color32 line = lineColor, free = freeColor, bad = busyColor;
for (int y = 0; y < h; y++)
{
int cy = y / PixelsPerCell, iy = y % PixelsPerCell;
for (int x = 0; x < w; x++)
{
int cx = x / PixelsPerCell, ix = x % PixelsPerCell;
bool edge = ix == 0 || iy == 0 || x == w - 1 || y == h - 1;
px[y * w + x] = busy[cx, cy] ? bad : (edge ? line : free);
if (busy[cx, cy] && edge) px[y * w + x] = new Color32(bad.r, bad.g, bad.b, (byte)Mathf.Min(255, bad.a + 50));
}
}
overlayTex.SetPixels32(px);
overlayTex.Apply(false);
}
}
// Размер здания на сетке и где его "пятно" относительно точки, в которую ставится префаб.
// size -- BuildingData.footprint, а если там 0 × 0 -- коллайдер префаба (иначе картинка), округлённый до клеток.
// anchorOffset -- центр пятна минус позиция префаба (у многих картинок точка привязки не в центре).
public struct BuildFootprint
{
public Vector2Int size;
public Vector2 anchorOffset;
static readonly Dictionary<BuildingData, BuildFootprint> cache = new Dictionary<BuildingData, BuildFootprint>();
public static BuildFootprint Of(BuildingData data)
{
if (data == null) return new BuildFootprint { size = Vector2Int.one };
if (cache.TryGetValue(data, out var fp)) return fp;
Rect r = LocalRect(data.prefab);
fp.anchorOffset = r.center;
if (data.footprint.x > 0 && data.footprint.y > 0) fp.size = data.footprint;
else fp.size = new Vector2Int(Mathf.Max(1, Mathf.RoundToInt(r.width)), Mathf.Max(1, Mathf.RoundToInt(r.height)));
cache[data] = fp;
return fp;
}
// Прямоугольник префаба относительно его позиции, в единицах мира.
// Считаем по трансформам руками: у выключенного шаблона collider.bounds не работает.
static Rect LocalRect(GameObject prefab)
{
if (prefab == null) return new Rect(-0.5f, -0.5f, 1, 1);
Vector3 root = prefab.transform.position;
var box = prefab.GetComponent<BoxCollider2D>();
if (box == null) box = prefab.GetComponentInChildren<BoxCollider2D>(true);
if (box != null)
{
Vector2 half = box.size * 0.5f;
return Corners(box.transform, box.offset - half, box.offset + half, root);
}
bool any = false;
Rect result = new Rect();
foreach (var sr in prefab.GetComponentsInChildren<SpriteRenderer>(true))
{
if (sr.sprite == null) continue;
var b = sr.sprite.bounds;
Rect one = Corners(sr.transform, b.min, b.max, root);
if (!any) { result = one; any = true; }
else result = Rect.MinMaxRect(Mathf.Min(result.xMin, one.xMin), Mathf.Min(result.yMin, one.yMin),
Mathf.Max(result.xMax, one.xMax), Mathf.Max(result.yMax, one.yMax));
}
return any ? result : new Rect(-0.5f, -0.5f, 1, 1);
}
static Rect Corners(Transform t, Vector2 min, Vector2 max, Vector3 root)
{
Vector3 a = t.TransformPoint(new Vector3(min.x, min.y)) - root;
Vector3 b = t.TransformPoint(new Vector3(max.x, max.y)) - root;
Vector3 c = t.TransformPoint(new Vector3(min.x, max.y)) - root;
Vector3 d = t.TransformPoint(new Vector3(max.x, min.y)) - root;
return Rect.MinMaxRect(Mathf.Min(a.x, b.x, c.x, d.x), Mathf.Min(a.y, b.y, c.y, d.y),
Mathf.Max(a.x, b.x, c.x, d.x), Mathf.Max(a.y, b.y, c.y, d.y));
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1986efcb01c943548be8d0a24fbc56c9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -2,10 +2,9 @@ using UnityEngine;
using System.Collections.Generic;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Управляет режимом строительства. Теперь здание можно поставить ТОЛЬКО
// на заранее подготовленный участок (BuildSlot) -- свободное размещение убрано.
// Наведение на пустой слот -> подсветка (можно строить).
// Наведение на занятый слот / мимо слотов -> подсветки нет, клик ничего не делает.
// Список зданий и сама постройка.
// Сейчас стройка свободная, на сетке: игрок сам выбирает место (BuildGrid, BuildGhost, BuildModeController),
// а здание ставит BuildAt. Участки (BuildSlot) остались для старых сцен и сохранений -- BuildGrid их прячет.
public class BuildManager : MonoBehaviour
{
@@ -148,7 +147,54 @@ public class BuildManager : MonoBehaviour
Debug.Log("[СТРОЙКА] Размещение отменено.");
}
// ===== Новая схема стройки: тап по участку -> выбор здания (BuildModeController) =====
// ===== Свободная стройка на сетке =====
// Данные здания по имени (имя хранится в PlacedBuildingId и в сохранении)
public BuildingData FindData(string buildingName)
{
if (allBuildings == null || string.IsNullOrEmpty(buildingName)) return null;
foreach (var b in allBuildings) if (b != null && b.buildingName == buildingName) return b;
return null;
}
// Можно ли поставить здание на клетки (левая нижняя cell): клетки свободны и ресурсов хватает
public bool CanBuildAt(BuildingData data, Vector2Int cell)
{
if (data == null || data.prefab == null) return false;
return BuildGrid.Instance.CanPlace(cell, BuildFootprint.Of(data).size) && data.CanAfford();
}
// Построить здание на клетках сетки. Возвращает здание или null (занято / мало ресурсов).
public GameObject BuildAt(BuildingData data, Vector2Int cell)
{
var grid = BuildGrid.Instance;
grid.Refresh(); // занятость на этот момент, а не 0.3 с назад
if (!CanBuildAt(data, cell)) return null;
long buildMs = BuildTimer.DurationMs(false, 1);
if (buildMs > 0 && BuildTimer.FreeBuilders == 0) return null; // все строители заняты
data.PayCost();
var fp = BuildFootprint.Of(data);
Vector2 center = grid.RectCenter(cell, fp.size);
Vector3 pos = new Vector3(center.x - fp.anchorOffset.x, center.y - fp.anchorOffset.y, 0f);
GameObject built = Instantiate(data.prefab, pos, Quaternion.identity);
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
built.AddComponent<PlacedBuildingId>().buildingName = data.buildingName;
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 });
grid.Refresh();
return built;
}
// ===== Старая схема: тап по участку -> выбор здания (участки спрятаны, оставлено для совместимости) =====
// Построить здание сразу на указанном участке. true -- получилось.
public bool BuildOnSlot(BuildingData data, BuildSlot slot)
@@ -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
@@ -17,6 +17,10 @@ public class BuildingData : ScriptableObject
[Tooltip("Необходимый уровень ратуши")]
public int requiredTownHallLevel = 1;
[Header("Размер на сетке стройки")]
[Tooltip("Сколько клеток занимает здание (ширина × высота). 0 × 0 -- посчитать само по коллайдеру префаба")]
public Vector2Int footprint;
[Header("Стоимость")]
public ResourceCost[] cost;
@@ -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 });
}
+16 -3
View File
@@ -1,13 +1,18 @@
using System;
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Вешается на объект Ратуши, который уже стоит на карте.
// Хранит текущий уровень -- от него зависит, какие здания разблокированы в BuildManager.
// Тап по Ратуше открывает окно Ратуши (слой приложения подписан на Tapped), улучшение -- кнопкой в окне.
public class TownHall : MonoBehaviour
{
public static TownHall Instance { get; private set; }
// Тап по Ратуше: интерфейс откроет её окно (модуль про интерфейс не знает)
public static event Action<TownHall> Tapped;
[Header("Уровень")]
public int currentLevel = 1;
public int maxLevel = 10;
@@ -64,10 +69,18 @@ public class TownHall : MonoBehaviour
EventBus.Publish(new TownHallLevelChangedEvent { Level = currentLevel });
Debug.Log($"[РАТУША] Улучшена до уровня {currentLevel}!");
}
Vector3 downPos;
void OnMouseDown()
{
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен улучшать Ратушу под ней
Debug.Log($"[Ратуша] Клик! текущий уровень: {currentLevel}. Улучшение...");
Upgrade();
downPos = Input.mousePosition;
}
// Отпустил палец на Ратуше, почти не сдвинув (а не тащил камеру) -- окно Ратуши. Раньше тап сразу улучшал
void OnMouseUpAsButton()
{
if (UIBlocker.IsPointerOverUI()) return; // клик по панели HUD не должен проходить на Ратушу под ней
if ((Input.mousePosition - downPos).magnitude > 25f) return;
Tapped?.Invoke(this);
}
}
@@ -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
+276 -4
View File
@@ -69,9 +69,9 @@ public class PopulationTests
Assert.AreEqual(300, p.Total);
Assert.AreEqual(270, p.Civilians);
Assert.AreEqual(120, p.Workforce, "могут работать 120");
Assert.AreEqual(270, p.Workforce, "работать может любой житель -- общего ограничения нет (регламент 31.10)");
Assert.AreEqual(0, p.Workers, "рабочих расставляет игрок (или CitySync по зданиям)");
Assert.AreEqual(120, p.FreeWorkers);
Assert.AreEqual(270, p.FreeWorkers);
Assert.AreEqual(30, p.Soldiers);
Assert.AreEqual(33f, p.FoodPerHour, 0.001f);
@@ -118,6 +118,274 @@ public class PopulationTests
Assert.IsTrue(p.IsStarving);
}
// ---------------- Найм воинов из жителей (решение команды от 19.10.2026) ----------------
[Test]
public void Recruit_150Free_Reserve30_Max120()
{
settlement.TownHallLevel = 6; // резерв 30 -- с 6 уровня Ратуши
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120); // 120 рабочих -- свободных 150
Assert.AreEqual(150, p.Idle);
Assert.AreEqual(30, p.CivilianReserve);
Assert.AreEqual(120, p.Recruitable, "пример команды: 150 свободных, резерв 30 -> 120");
Assert.IsFalse(p.TryRecruit(121));
Assert.IsTrue(p.TryRecruit(120));
Assert.AreEqual(0, p.Recruitable);
}
[Test]
public void Recruit_TakesCivilians_TotalStaysTheSame()
{
var p = StartCity();
Assert.IsTrue(p.TryRecruit(20));
Assert.AreEqual(250, p.Civilians);
Assert.AreEqual(20, p.InTraining);
Assert.AreEqual(300, p.Total, "житель стал новобранцем -- людей столько же");
p.RecruitsTrained(20);
army.AddTroops(0, 20); // Казармы выпускают воинов в Армию
Assert.AreEqual(0, p.InTraining);
Assert.AreEqual(50, p.Soldiers);
Assert.AreEqual(300, p.Total, "новобранцы не посчитаны дважды");
}
// ---------------- Группы людей не пересекаются (регламент 31.10) ----------------
static void AssertGroupsAddUp(PopulationService p, string when)
{
int free = p.Idle, workers = p.Workers, recruits = p.InTraining, soldiers = p.Soldiers;
Assert.AreEqual(p.Total, free + workers + recruits + soldiers, "свободные + рабочие + новобранцы + воины = все люди: " + when);
Assert.AreEqual(p.Civilians, free + workers, "жители = свободные + рабочие: " + when);
Assert.GreaterOrEqual(free, 0, when);
}
[Test]
public void Groups_NeverOverlap_SumEqualsTotal()
{
var p = StartCity();
AssertGroupsAddUp(p, "старт");
p.SetWorkers(WorkJob.Farmer, 100);
AssertGroupsAddUp(p, "поставили 100 рабочих");
Assert.IsTrue(p.TryRecruit(150)); // берутся только свободные: 170 - резерв 10 = 160 доступно
Assert.AreEqual(100, p.Workers, "рабочих в новобранцы не взяли");
AssertGroupsAddUp(p, "наняли 150");
p.RecruitsTrained(150);
army.AddTroops(0, 150);
AssertGroupsAddUp(p, "новобранцы стали воинами");
Assert.AreEqual(300, p.Total, "людей не прибавилось и не убавилось");
}
[Test]
public void Workers_CannotExceedFreeCivilians()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 100);
p.TryRecruit(150); // жителей осталось 120: 100 рабочих + 20 свободных
Assert.AreEqual(20, p.SetWorkers(WorkJob.Woodcutter, 50), "поставить можно только 20 свободных -- не воинов и не новобранцев");
Assert.AreEqual(0, p.FreeWorkers);
Assert.AreEqual(0, p.SetWorkers(WorkJob.Stonecutter, 10), "свободных нет -- никого");
AssertGroupsAddUp(p, "все жители на работе");
}
[Test]
public void Restore_WorkersNeverMoreThanCivilians()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120);
string save = p.Capture().Replace("civilians=270", "civilians=50"); // испорченное сохранение
var copy = new PopulationService(new PopulationRules());
copy.Restore(save);
Assert.AreEqual(50, copy.Workers, "рабочих не больше, чем жителей");
Assert.AreEqual(0, copy.Idle);
}
// ---------------- Интеграция: новобранцы переходят из Населения в Армию (настоящий ArmyService) ----------------
[Test]
public void Training_RecruitsMoveToArmy_NoOneLostOrDuplicated()
{
var p = StartCity();
int infantryBefore = army.GetTroops(0);
Assert.IsTrue(p.TryRecruit(20));
AssertGroupsAddUp(p, "новобранцы в Казармах");
Assert.IsTrue(p.CompleteTraining(0, 20));
Assert.AreEqual(0, p.InTraining);
Assert.AreEqual(infantryBefore + 20, army.GetTroops(0), "в Армии +20 пехоты");
Assert.AreEqual(300, p.Total, "людей столько же");
AssertGroupsAddUp(p, "обучение закончено");
}
[Test]
public void Training_ArmyRejects_RecruitsStayInBarracks()
{
var p = StartCity();
p.TryRecruit(20);
int soldiersBefore = p.Soldiers;
Assert.IsFalse(p.CompleteTraining(99, 20), "такого типа войск нет -- Армия не приняла");
Assert.AreEqual(20, p.InTraining, "новобранцы не пропали");
Assert.AreEqual(soldiersBefore, p.Soldiers, "и воинов из ничего не появилось");
Assert.AreEqual(300, p.Total);
AssertGroupsAddUp(p, "Армия не приняла");
}
[Test]
public void Training_RepeatedCompletion_DoesNotDuplicate()
{
var p = StartCity();
p.TryRecruit(20);
Assert.IsTrue(p.CompleteTraining(0, 20));
Assert.IsFalse(p.CompleteTraining(0, 20), "второй раз новобранцев уже нет");
Assert.AreEqual(50, p.Soldiers, "30 стартовых + 20, а не 70");
Assert.AreEqual(300, p.Total);
}
[Test]
public void Training_NoArmyModule_NothingChanges()
{
var p = StartCity();
p.TryRecruit(20);
GameServices.Unregister<IArmy>(army);
Assert.IsFalse(p.CompleteTraining(0, 20));
Assert.AreEqual(20, p.InTraining, "без Армии передавать некуда -- новобранцы ждут");
}
[Test]
public void Training_PartOfRecruits_OthersKeepTraining()
{
var p = StartCity();
p.TryRecruit(20);
Assert.IsTrue(p.CompleteTraining(1, 8)); // 8 лучников готовы
Assert.AreEqual(12, p.InTraining);
Assert.AreEqual(8, army.GetTroops(1));
Assert.AreEqual(300, p.Total);
AssertGroupsAddUp(p, "часть обучена");
}
[Test]
public void Reserve_DependsOnTownHallLevel_10_20_30()
{
var p = StartCity();
int[] expected = { 10, 10, 20, 20, 20, 30, 30, 30, 30, 30, 30 }; // 11 уровень -- как последний
for (int level = 1; level <= expected.Length; level++)
{
settlement.TownHallLevel = level;
Assert.AreEqual(expected[level - 1], p.CivilianReserve, "Ратуша " + level);
}
}
[Test]
public void Recruit_StartCity_Reserve10()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120);
Assert.AreEqual(10, p.CivilianReserve, "Ратуша 1 -- резерв 10");
Assert.AreEqual(140, p.Recruitable, "150 свободных - 10");
}
[Test]
public void Recruit_TakesOnlyFree_WorkersStayAtWork()
{
var p = StartCity();
p.SetWorkers(WorkJob.Farmer, 120);
p.TryRecruit(140);
Assert.AreEqual(120, p.Workers, "рабочие остались на работе");
Assert.AreEqual(10, p.Idle, "остался резерв");
Assert.AreEqual(0, p.Recruitable);
}
[Test]
public void AutoFillAll_NewGame_Places120Workers_RestStayFree()
{
var p = StartCity();
p.AutoFillAll(); // без модуля добычи мест не меньше -- ограничивает только старт: 120 рабочих
Assert.AreEqual(120, p.Workers);
Assert.AreEqual(150, p.Idle);
}
[Test]
public void Recruits_EatLikeSoldiers_AndAreSaved()
{
var p = StartCity();
p.TryRecruit(10);
Assert.AreEqual(260 * 0.1 + (30 + 10) * 0.2, p.FoodPerHour, 0.001);
var copy = new PopulationService(new PopulationRules());
copy.Restore(p.Capture());
Assert.AreEqual(10, copy.InTraining);
Assert.AreEqual(260, copy.Civilians);
copy.ResetState();
Assert.AreEqual(0, copy.InTraining);
}
[Test]
public void OldTrainingOrder_WithoutRecruits_DoesNotGoNegative()
{
var p = StartCity();
p.RecruitsTrained(5); // заказ из старого сохранения -- людей тогда не брали
Assert.AreEqual(0, p.InTraining);
Assert.AreEqual(270, p.Civilians);
}
// ---------------- История голода (вопрос Саши 7): бой, прошедший офлайн, знает голод на свой момент ----------------
[Test]
public void FamineHistory_OfflineBattleSeesHungerAtItsMoment()
{
economy.Set(ResourceType.Food, 50); // 33 еды в час -- хватит примерно на полтора часа
var p = StartCity();
p.Tick(T0 + 5 * Hour); // игра была закрыта 5 часов
Assert.IsFalse(p.WasStarvingAt(T0 + Hour), "через час еда ещё была");
Assert.IsTrue(p.WasStarvingAt(T0 + 2 * Hour), "через два часа -- голод");
Assert.IsTrue(p.WasStarvingAt(T0 + 5 * Hour), "и сейчас голод");
economy.Set(ResourceType.Food, 1000); // привезли еду
p.Tick(T0 + 8 * Hour);
Assert.IsFalse(p.IsStarving);
Assert.IsTrue(p.WasStarvingAt(T0 + 3 * Hour), "прошлый голод помнится");
Assert.IsFalse(p.WasStarvingAt(T0 + 7 * Hour), "после еды голода нет");
}
[Test]
public void FamineHistory_SurvivesSave_AndIsClearedByReset()
{
economy.Set(ResourceType.Food, 50);
var p = StartCity();
p.Tick(T0 + 5 * Hour);
economy.Set(ResourceType.Food, 1000);
p.Tick(T0 + 8 * Hour);
var copy = new PopulationService(new PopulationRules());
copy.Restore(p.Capture());
Assert.IsTrue(copy.WasStarvingAt(T0 + 3 * Hour));
Assert.IsFalse(copy.WasStarvingAt(T0 + Hour));
copy.ResetState();
Assert.IsFalse(copy.WasStarvingAt(T0 + 3 * Hour), "новая игра -- истории нет");
}
[Test]
public void FamineHistory_OlderThan48Hours_IsForgotten()
{
economy.Set(ResourceType.Food, 50);
var p = StartCity();
p.Tick(T0 + 5 * Hour);
economy.Set(ResourceType.Food, 10000);
p.Tick(T0 + 8 * Hour);
p.Tick(T0 + 60 * Hour);
Assert.IsFalse(p.WasStarvingAt(T0 + 3 * Hour), "голод 57 часов назад бою уже не нужен");
Assert.IsFalse(p.IsStarving);
}
[Test]
public void FoodReturns_StarvationEnds_GrowthResumes()
{
@@ -188,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();
@@ -197,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]
+155 -11
View File
@@ -59,7 +59,7 @@ public class ProductionTests
Assert.AreEqual(22.5f, production.RatePerHour(ResourceType.Stone), 0.001f);
Assert.AreEqual(15f, production.RatePerHour(ResourceType.Iron), 0.001f);
Assert.AreEqual(100, population.Workers);
Assert.AreEqual(20, population.FreeWorkers);
Assert.AreEqual(170, population.FreeWorkers, "работать может любой из 270 жителей");
}
[Test]
@@ -93,9 +93,10 @@ public class ProductionTests
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1))); // 30 мест
Assert.AreEqual(30, population.SetWorkers(WorkJob.Woodcutter, 50), "больше мест не поставить");
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Farmer, 1),
(WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
Assert.AreEqual(90, population.SetWorkers(WorkJob.Farmer, 120), "свободных осталось 90 из 120");
var farms = new List<KeyValuePair<WorkJob, int>> { new KeyValuePair<WorkJob, int>(WorkJob.Woodcutter, 1) };
for (int i = 0; i < 9; i++) farms.Add(new KeyValuePair<WorkJob, int>(WorkJob.Farmer, 1)); // 270 мест
production.SetBuildings(farms);
Assert.AreEqual(240, population.SetWorkers(WorkJob.Farmer, 270), "свободных жителей осталось 240 из 270");
Assert.AreEqual(0, population.FreeWorkers);
}
@@ -137,18 +138,69 @@ public class ProductionTests
Assert.AreEqual(30, population.GetWorkers(WorkJob.Woodcutter));
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
Assert.AreEqual(30, population.GetWorkers(WorkJob.Stonecutter));
Assert.AreEqual(30, population.FreeWorkers);
Assert.AreEqual(180, population.FreeWorkers);
Assert.IsTrue(population.JobsInitialized);
}
[Test]
public void AutoFill_NewBuilding_TakesFreeWorkers()
public void AutoFillAll_StartCity_NoMoreThan120Workers()
{
production.SetBuildings(Buildings((WorkJob.Woodcutter, 1), (WorkJob.Farmer, 1), (WorkJob.Stonecutter, 1),
(WorkJob.MinerIron, 1), (WorkJob.MinerGold, 1))); // 150 мест
population.AutoFillAll();
Assert.AreEqual(120, population.Workers, "стартовый город: 120 рабочих");
Assert.AreEqual(24, population.GetWorkers(WorkJob.Woodcutter), "поровну по профессиям");
}
// ---------------- Голод (регламент 31.10, ТЕСТ): добыча кроме еды ×0.5 ----------------
[Test]
public void Famine_HalvesNonFoodProduction_FoodUnaffected_PenaltyEndsWhenFed()
{
economy.Set(ResourceType.Food, 0);
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Woodcutter, 1)));
population.SetWorkers(WorkJob.Farmer, 5); // 10 еды в час -- город ест 33, голод
population.SetWorkers(WorkJob.Woodcutter, 30); // 60 дерева в час
population.Tick(T0 + Hour);
Assert.IsTrue(population.IsStarving);
Assert.AreEqual(30f, production.RatePerHour(ResourceType.Wood), 0.001f, "дерево вдвое меньше");
Assert.AreEqual(10f, production.RatePerHour(ResourceType.Food), 0.001f, "еда без штрафа");
economy.Set(ResourceType.Food, 1000); // привезли еду
population.Tick(T0 + 2 * Hour);
Assert.IsFalse(population.IsStarving);
Assert.AreEqual(60f, production.RatePerHour(ResourceType.Wood), 0.001f, "сыты -- штраф снят");
}
[Test]
public void NewBuilding_SlotsStayEmpty_UntilPlayerAssigns()
{
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer));
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1)));
Assert.AreEqual(30, population.AutoFill(WorkJob.Farmer), "вторая ферма -- ещё 30");
Assert.AreEqual(60, population.GetWorkers(WorkJob.Farmer));
population.AutoFillAll(); // стартовая расстановка -- один раз
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
production.SetBuildings(Buildings((WorkJob.Farmer, 1), (WorkJob.Farmer, 1))); // построили вторую ферму
population.ClampJobs();
Assert.AreEqual(60, production.Slots(WorkJob.Farmer));
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer), "новые места пустые -- сами не заполняются");
Assert.AreEqual(60, population.SetWorkers(WorkJob.Farmer, 60), "игрок ставит сам");
}
[Test]
public void StartPlacement_HappensOnlyOnce()
{
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
population.AutoFillAll();
Assert.IsTrue(population.JobsInitialized);
population.SetWorkers(WorkJob.Farmer, 0); // игрок снял всех
// Сохранение и загрузка: расстановка уже была, повторно не нужна
var copy = new PopulationService(new PopulationRules());
copy.Restore(population.Capture());
Assert.IsTrue(copy.JobsInitialized, "второй раз CitySync расстановку не вызовет");
Assert.AreEqual(0, copy.GetWorkers(WorkJob.Farmer));
}
[Test]
@@ -159,7 +211,7 @@ public class ProductionTests
production.SetBuildings(Buildings((WorkJob.Farmer, 1)));
population.ClampJobs();
Assert.AreEqual(30, population.GetWorkers(WorkJob.Farmer));
Assert.AreEqual(90, population.FreeWorkers);
Assert.AreEqual(240, population.FreeWorkers);
}
[Test]
@@ -193,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 минут + конец");
}
}
+204 -20
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,13 +27,19 @@ 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; // разведка дошла до цели и повернула домой
// Время мира. В мультиплеере -- время сервера, а не телефона
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
public static long NowMs => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + GameClock.OffsetSeconds * 1000;
private readonly List<WorldEvent> events = new List<WorldEvent>();
private WorldSimulation world;
private List<MarchData> pendingMarches; // загружены раньше, чем мир был создан
private List<CampState> pendingCamps;
private ResourcePointsState pendingPoints;
private PatrolState pendingPatrols;
// Мир создаётся при первом обращении: к этому моменту Боевая система уже зарегистрирована
public WorldSimulation World
@@ -41,14 +48,29 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
if (world == null)
{
world = new WorldSimulation(WorldMapLayout.CreateDefault(), GameServices.Get<IBattleSystem>());
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()
{
@@ -61,6 +83,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
Instance = this;
GameServices.Register<IWorld>(this);
GameServices.Register<IWorldMap>(this);
SaveRegistry.Register(this);
}
@@ -68,6 +91,7 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
{
if (Instance != this) return;
GameServices.Unregister<IWorld>(this);
GameServices.Unregister<IWorldMap>(this);
SaveRegistry.Unregister(this);
}
@@ -76,57 +100,155 @@ 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();
}
// Состояние войск игрока сейчас: голод -- из модуля Населения. Для окна атаки (прогноз).
// Усталость пока не выставляется -- правила её учёта не утверждены.
public static BattleCondition PlayerConditions()
{
return GameServices.TryGet(out IPopulation population) && population.IsStarving
? BattleCondition.Famine
: 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))
{
error = "Нет данных о войске -- запусти игру из города";
return false;
}
if (army.SoldiersAtHome < soldiers)
if (army.AvailableForMarch < soldiers)
{
error = "Не хватает воинов. Наймите их в Казармах";
error = army.GarrisonTotal > 0
? $"Не хватает свободных воинов: {army.GarrisonTotal} стоят в гарнизоне. Гарнизон меняется в окне «Войска»"
: "Не хватает воинов. Наймите их в Казармах";
return false;
}
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error);
// Слоты похода (регламент 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;
// Ушедшие воины недоступны дома, пока не вернутся
army.TakeForMarch(march.id, soldiers);
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
if (!army.TakeForMarch(march.id, soldiers))
{
World.CancelMarch(march.id);
error = "Не удалось собрать войско в поход";
return false;
}
// Состав похода -- для боя с типами войск и для скорости: время прихода по средней скорости состава
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;
}
void HandleBattleResolved(MarchData march)
{
MapNode target = World.GetNode(march.targetNodeId);
Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя");
else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " +
$"{march.soldiers} против {march.defenders}, возвращаются {march.survivors}.");
OnBattleResolved?.Invoke(march);
EventBus.Publish(new MarchBattleResolvedEvent
{
MarchId = march.id,
BattleId = march.battleSeed,
TimeMs = march.arriveMs,
TargetId = march.targetNodeId,
Victory = march.victory,
Soldiers = march.soldiers,
@@ -134,10 +256,37 @@ 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)
{
GameServices.Get<IArmy>().ReturnFromMarch(march.id, march.survivors);
IArmy army = GameServices.Get<IArmy>();
bool returned = march.survivorUnits != null && march.survivorUnits.Length == TroopType.Count
? army.ReturnUnits(march.id, march.survivorUnits, march.woundedUnits, march.returnMs) // по типам; раненые -- в госпиталь
: army.ReturnFromMarch(march.id, march.survivors); // старый поход без состава -- по пропорции
// Армия не знает этот поход (уже зачислен или испорченное сохранение): ни воинов, ни добычи
if (!returned)
{
Debug.LogWarning($"[ПОХОД] Поход {march.id} неизвестен Армии -- воины и добыча не зачислены");
return;
}
IEconomy economy = GameServices.Get<IEconomy>();
foreach (var reward in march.loot)
@@ -153,10 +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,
TargetName = World.GetNode(march.targetNodeId)?.displayName ?? "",
TargetEmpty = march.targetEmpty,
IsGather = march.gather,
IsScout = march.scout,
Wounded = Sum(march.woundedUnits),
TargetName = World.TargetName(march),
LootText = FormatLoot(march.loot)
});
}
@@ -167,25 +321,48 @@ public class MarchManager : MonoBehaviour, IWorld, ISaveParticipant
class WorldSaveData
{
public List<MarchData> marches = new List<MarchData>();
public List<CampState> camps = new List<CampState>(); // разбитые лагеря и когда они появятся
public ResourcePointsState points; // ресурсные точки: запасы, сроки, кто собирает
public PatrolState patrols; // патрули-заглушки: когда следующий
}
public string SaveKey => "world";
public string Capture()
{
return JsonUtility.ToJson(new WorldSaveData { marches = GetMarchesForSave() });
var camps = world != null ? world.GetCampStates() : new List<CampState>(pendingCamps ?? new List<CampState>());
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)
{
LoadMarches(JsonUtility.FromJson<WorldSaveData>(json)?.marches);
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;
}
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом)
// Новая игра: все походы отменяются (войска сбрасывает Армия вместе с прогрессом), лагеря на месте
public void ResetState()
{
pendingMarches = null;
if (world != null) world.LoadMarches(new List<MarchData>());
pendingCamps = null;
pendingPoints = null;
pendingPatrols = null;
if (world != null)
{
world.LoadPatrolState(null);
world.LoadMarches(new List<MarchData>());
world.LoadCampStates(null);
world.LoadPointsState(null); // ресурсные точки появятся заново
}
}
public List<MarchData> GetMarchesForSave()
@@ -200,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)
@@ -7,7 +7,9 @@ namespace Bogatyri.World
{
PlayerCity, // город игрока
NeighborCity, // соседний город -- другой игрок
BossCastle // Большой замок с боссом в центре карты
BossCastle, // Большой замок с боссом в центре карты
Camp, // PvE-лагерь разбойников: бьётся ради добычи, после победы появляется снова
ResourcePoint // ресурсная точка: войско собирает ресурс, точка истощается и исчезает (регламент 31.10)
}
[Serializable]
@@ -31,6 +33,7 @@ namespace Bogatyri.World
public bool canBeAttacked;
public int garrison; // солдат в обороне
public int level; // уровень лагеря (1..3), у остальных точек 0
public List<RewardEntry> rewards = new List<RewardEntry>();
}
@@ -54,6 +57,22 @@ namespace Bogatyri.World
const int MinCitySpacing = 3600; // минимальное расстояние между городами
const int MaxPlacementAttempts = 1000;
// Лагеря разбойников (PvE). Ставятся отдельным генератором после городов -- карта городов не изменилась.
public const int CampCount = 12;
public const int CampLevels = 3;
const int CampSpacing = 2600; // лагерь может стоять ближе к городу, чем город к городу
const int CampSeedSalt = 0x5A17C0DE;
// Требует балансировки. Небольшие числа -- по концепции экономики.
static readonly string[] CampNames = { "Стан разбойников", "Логово лиходеев", "Становище Соловья-разбойника" };
static readonly int[] CampGarrison = { 6, 14, 26 }; // + 0..3 случайно
static readonly int[][] CampRewards = // еда, дерево, золото, металл
{
new[] { 15, 15, 0, 0 },
new[] { 25, 25, 8, 0 },
new[] { 40, 40, 15, 8 }
};
// Пока соседи -- заглушки с именами. Потом это будут ники реальных игроков.
static readonly string[] NeighborNames =
{
@@ -128,11 +147,45 @@ namespace Bogatyri.World
nodes.Add(city);
}
AddCamps(nodes, new DeterministicRandom(seed ^ CampSeedSalt), CampCount);
return nodes;
}
// Случайная точка, не слишком близко к замку и к другим городам
static bool TryFindFreeSpot(DeterministicRandom rng, List<MapNode> nodes, out int x, out int y)
static void AddCamps(List<MapNode> nodes, DeterministicRandom rng, int count)
{
string[] rewardTypes = { "Food", "Wood", "Gold", "Iron" };
for (int i = 0; i < count; i++)
{
if (!TryFindFreeSpot(rng, nodes, out int x, out int y, CampSpacing)) break;
int level = i % CampLevels + 1; // поровну лагерей каждого уровня
var camp = new MapNode
{
id = $"camp_{i + 1}",
displayName = CampNames[level - 1],
type = MapNodeType.Camp,
x = x,
y = y,
canBeAttacked = true,
level = level,
garrison = CampGarrison[level - 1] + rng.Range(0, 4)
};
int[] amounts = CampRewards[level - 1];
for (int r = 0; r < amounts.Length; r++)
if (amounts[r] > 0) camp.rewards.Add(new RewardEntry { resource = rewardTypes[r], amount = amounts[r] });
nodes.Add(camp);
}
}
// Защитники за стенами: замок и города. Лагерь -- бой в поле.
public static bool IsFortified(MapNode node)
{
return node != null && (node.type == MapNodeType.BossCastle || node.type == MapNodeType.NeighborCity);
}
// Случайная точка, не слишком близко к замку и к другим точкам
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++)
{
@@ -142,7 +195,7 @@ namespace Bogatyri.World
bool free = true;
foreach (var n in nodes)
{
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : MinCitySpacing;
int minDistance = n.type == MapNodeType.BossCastle ? CastleClearRadius : spacing;
if (DistanceSquared(n.x, n.y, x, y) < (long)minDistance * minDistance)
{
free = false;
@@ -157,6 +210,47 @@ namespace Bogatyri.World
return false;
}
// Состав гарнизона по типам войск (TroopType). Вычисляется из id цели, а не из генератора карты:
// так карта (координаты, гарнизоны) осталась прежней, а состав всегда одинаков у клиента и сервера.
public static int[] GarrisonUnits(MapNode node)
{
var units = new int[TroopType.Count];
if (node == null || node.garrison <= 0) return units;
int[] percent;
if (node.type == MapNodeType.BossCastle)
{
percent = new[] { 40, 30, 20, 10 }; // Змей Горыныч держит всех понемногу
}
else
{
uint h = StableHash(node.id);
int infantry = 30 + (int)(h % 31); // 30..60%
int archers = 10 + (int)(h / 31 % 26); // 10..35%
int cavalry = 5 + (int)(h / 806 % 21); // 5..25%
int siege = Math.Max(0, 100 - infantry - archers - cavalry);
if (node.type == MapNodeType.Camp) { infantry += siege; siege = 0; } // у разбойников нет осадных машин
percent = new[] { infantry, archers, cavalry, siege };
}
int given = 0;
for (int i = 0; i < TroopType.Count; i++)
{
units[i] = node.garrison * percent[i] / 100;
given += units[i];
}
units[TroopType.Infantry] += node.garrison - given; // остаток округления -- пехоте
return units;
}
// FNV-1a: одинаков на любой платформе (string.GetHashCode в .NET меняется между запусками)
static uint StableHash(string s)
{
uint hash = 2166136261;
foreach (char c in s ?? "") hash = (hash ^ c) * 16777619;
return hash;
}
public static MapNode Find(List<MapNode> nodes, string id)
{
foreach (var n in nodes)
@@ -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,12 +21,30 @@ 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 int defenders; // гарнизон цели в момент боя
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 и выпавшая случайность (десятые доли процента)
public long defenderPower;
public int attackerLuck;
public int defenderLuck;
public int attackerConditions; // BattleCondition нападающего в бою (голод, усталость)
public int attackerConditionPercent; // сколько силы оставили состояния: 100 -- без штрафов
public List<RewardEntry> loot = new List<RewardEntry>();
public long departMs;
@@ -33,49 +53,158 @@ 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 РЕАЛЬНЫХ минут после боя (план от 27.10.2026: "30 реальных минут"). ТЕСТ.
public const long RespawnMs = 30L * 60 * 1000;
}
// Разбитый лагерь: до какого момента его нет на карте. Хранится в сохранении мира.
[Serializable]
public class CampState
{
public string id;
public long respawnMs;
}
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 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;
}
// Состояние войска нападающего на момент боя (голод в его поселении, усталость).
// Мир сам голод не считает: его задаёт тот, кто знает поселение (MarchManager -- через модуль Населения,
// сервер -- по своим данным). Не задано -- без штрафов.
public Func<MarchData, BattleCondition> AttackerConditions { get; set; }
public MapNode GetNode(string id)
{
return WorldMapLayout.Find(nodes, id);
@@ -97,14 +226,56 @@ namespace Bogatyri.World
}
}
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error)
// ---------------- Лагеря ----------------
public bool IsCampCleared(string campId, long nowMs)
{
return campRespawn.TryGetValue(campId, out long at) && nowMs < at;
}
// Сколько миллисекунд до появления лагеря (0 -- лагерь на месте)
public long CampRespawnInMs(string campId, long nowMs)
{
return campRespawn.TryGetValue(campId, out long at) && nowMs < at ? at - nowMs : 0;
}
public List<CampState> GetCampStates()
{
var list = new List<CampState>();
foreach (var c in campRespawn) list.Add(new CampState { id = c.Key, respawnMs = c.Value });
return list;
}
public void LoadCampStates(List<CampState> saved)
{
campRespawn.Clear();
if (saved == null) return;
foreach (var c in saved)
if (c != null && GetNode(c.id) != null) campRespawn[c.id] = c.respawnMs;
}
// ---------------- Походы ----------------
// 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 (!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
{
@@ -115,59 +286,232 @@ 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
};
// Номер боя -- из данных похода: тот же поход всегда даёт тот же бой.
// В мультиплеере его выдаёт сервер, иначе клиент мог бы подбирать удачный.
march.battleSeed = BattleSeed(march.id, nowMs, targetId);
marches.Add(march);
error = null;
return march;
}
// Откат только что созданного похода: войска для него не удалось зарезервировать.
// Отменить можно только поход, который ещё не дошёл до цели (боя не было).
public bool CancelMarch(int marchId)
{
MarchData m = marches.Find(x => x.id == marchId);
if (m == null || m.state != MarchState.Outbound) return false;
marches.Remove(m);
return true;
}
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
// может и дойти, и вернуться -- события придут в правильном порядке.
public void Advance(long nowMs, List<WorldEvent> events)
{
foreach (var m in marches)
{
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs)
{
ResolveBattle(m);
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
}
EnsureResourcePoints(nowMs); // первая игра: ресурсные точки появляются на карте
SpawnPatrolsUntil(nowMs); // патрули-заглушки соседей (если включены)
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
// События -- строго по времени (документ 02: офлайн в причинно-следственном порядке).
// Каждый раз берётся самое раннее: приход к цели (бой или начало сбора), конец сбора,
// исчезновение ресурсной точки или возвращение домой. Событие назначает следующее время похода,
// поэтому за один вызов поход может дойти, собрать и вернуться. Кто первым дошёл до лагеря или точки --
// того и цель. При равном времени: приход, конец сбора, исчезновение точки, возвращение; затем по номеру.
while (true)
{
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();
foreach (var c in campRespawn) if (nowMs >= c.Value) expiredCamps.Add(c.Key);
foreach (var id in expiredCamps) campRespawn.Remove(id);
}
private readonly List<string> expiredCamps = new List<string>();
void ResolveBattle(MarchData m)
{
MapNode from = GetNode(m.fromNodeId);
MapNode target = GetNode(m.targetNodeId);
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest { Attackers = m.soldiers, Defenders = target.garrison });
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
m.returnMs = m.arriveMs + TravelTimeMs(target, from, HasUnits(m) ? m.units : null);
m.state = MarchState.Returning;
m.loot = new List<RewardEntry>();
// Лагерь уже разбит (другим войском): боя нет, все идут домой
if (target.type == MapNodeType.Camp && IsCampCleared(target.id, m.arriveMs))
{
m.targetEmpty = true;
m.victory = false;
m.survivors = m.soldiers;
m.survivorUnits = HasUnits(m) ? (int[])m.units.Clone() : null;
m.defenders = 0;
m.defenderLosses = 0;
return;
}
BattleCondition conditions = AttackerConditions != null ? AttackerConditions(m) : BattleCondition.None;
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest
{
AttackerUnits = HasUnits(m) ? m.units : null,
Attackers = m.soldiers,
DefenderUnits = WorldMapLayout.GarrisonUnits(target),
Defenders = target.garrison,
DefenderFortified = WorldMapLayout.IsFortified(target), // замок и города -- за стенами, лагерь -- в поле
Seed = m.battleSeed != 0 ? m.battleSeed : BattleSeed(m.id, m.departMs, m.targetNodeId), // старые походы без номера
AttackerConditions = conditions
});
m.attackerConditions = (int)conditions;
m.attackerConditionPercent = outcome.AttackerConditionPercent;
m.attackerPower = outcome.AttackerPower;
m.defenderPower = outcome.DefenderPower;
m.attackerLuck = outcome.AttackerLuckPermille;
m.defenderLuck = outcome.DefenderLuckPermille;
m.victory = outcome.Victory;
m.survivors = outcome.Survivors;
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
m.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;
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
// их потери будет списывать сервер из армии того игрока.
m.loot = new List<RewardEntry>();
if (outcome.Victory)
{
foreach (var r in target.rewards)
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
}
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
// Разбитый лагерь пропадает с карты и появляется снова -- отсчёт от момента боя
if (target.type == MapNodeType.Camp) campRespawn[target.id] = m.arriveMs + CampRules.RespawnMs;
}
}
// Разведка у цели: боя, сбора и потерь нет -- все идут домой тем же составом. Цели уже нет (точка исчезла) --
// дошли до места и вернулись физически, без телепортации (решения 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)
{
ulong h = 14695981039346656037UL;
void Mix(ulong v) { for (int i = 0; i < 8; i++) { h = (h ^ (v & 0xFF)) * 1099511628211UL; v >>= 8; } }
Mix((ulong)marchId);
Mix((ulong)departMs);
foreach (char c in targetId ?? "") Mix(c);
return (long)h;
}
// Состав похода известен и сходится с числом воинов (у старых походов его нет)
static bool HasUnits(MarchData m)
{
if (m.units == null || m.units.Length != TroopType.Count) return false;
int n = 0;
foreach (var u in m.units) n += u;
return n == m.soldiers && n > 0;
}
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
@@ -184,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;
}
+11 -4
View File
@@ -31,12 +31,19 @@ public static class MarchReturnPopup
showing = true;
MarchReturnedEvent e = queue.Dequeue();
string target = string.IsNullOrEmpty(e.TargetName) ? "поход" : $"Поход на «{e.TargetName}»";
string result = e.Victory ? "<color=#7CD67C>победа</color>" : "<color=#E06A5A>поражение</color>";
int lost = Mathf.Max(0, e.Sent - e.Survivors);
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 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);
+43 -5
View File
@@ -2,13 +2,19 @@ using UnityEngine;
// Подгоняет RectTransform под безопасную зону экрана (Screen.safeArea): вырез камеры, скруглённые углы,
// полоска "домой" на iPhone. Следит за поворотом и сменой разрешения (и в Device Simulator).
// Вешать на растянутый на весь Canvas объект; всё, что внутри, не залезет под вырез.
// Вешать на растянутый объект; всё, что внутри, не залезет под вырез.
// - fitInsideParent = false: объект растянут на весь Canvas (HUD);
// - fitInsideParent = true: родитель не на весь экран (окно между верхней и нижней панелью) --
// зона пересчитывается в координаты родителя, отступ появляется только там, где родитель заходит под вырез.
[RequireComponent(typeof(RectTransform))]
public class SafeArea : MonoBehaviour
{
public bool fitInsideParent;
private RectTransform rect;
private Rect appliedArea;
private Vector2Int appliedScreen;
private Rect appliedParent;
void Awake()
{
@@ -18,7 +24,8 @@ public class SafeArea : MonoBehaviour
void Update()
{
if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y)
if (Screen.safeArea != appliedArea || Screen.width != appliedScreen.x || Screen.height != appliedScreen.y
|| (fitInsideParent && ParentScreenRect() != appliedParent))
Apply();
}
@@ -29,10 +36,41 @@ public class SafeArea : MonoBehaviour
appliedScreen = new Vector2Int(Screen.width, Screen.height);
if (Screen.width <= 0 || Screen.height <= 0) return;
// Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком
rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height);
rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height);
if (!fitInsideParent)
{
// Якоря в долях экрана: Canvas в режиме Screen Space Overlay покрывает экран целиком
rect.anchorMin = new Vector2(area.xMin / Screen.width, area.yMin / Screen.height);
rect.anchorMax = new Vector2(area.xMax / Screen.width, area.yMax / Screen.height);
}
else
{
Rect parent = ParentScreenRect();
appliedParent = parent;
if (parent.width <= 0 || parent.height <= 0) return;
// Пересечение родителя с безопасной зоной -- в долях родителя
float xMin = Mathf.Max(parent.xMin, area.xMin), xMax = Mathf.Min(parent.xMax, area.xMax);
float yMin = Mathf.Max(parent.yMin, area.yMin), yMax = Mathf.Min(parent.yMax, area.yMax);
if (xMax <= xMin || yMax <= yMin) return;
rect.anchorMin = new Vector2((xMin - parent.xMin) / parent.width, (yMin - parent.yMin) / parent.height);
rect.anchorMax = new Vector2((xMax - parent.xMin) / parent.width, (yMax - parent.yMin) / parent.height);
}
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
}
// Прямоугольник родителя в пикселях экрана
Rect ParentScreenRect()
{
var parent = transform.parent as RectTransform;
if (parent == null) return new Rect(0, 0, Screen.width, Screen.height);
var corners = new Vector3[4];
parent.GetWorldCorners(corners);
Canvas canvas = parent.GetComponentInParent<Canvas>();
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null;
Vector2 min = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 max = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
return Rect.MinMaxRect(min.x, min.y, max.x, max.y);
}
}
+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}";
}
}
+418 -19
View File
@@ -44,15 +44,18 @@ public class WorldMapController : MonoBehaviour
public TMP_Text popupBodyText;
public Button popupOkButton;
const string DefaultHint = "Нажми на замок или соседний город, чтобы напасть. Карту можно двигать и приближать";
const string DefaultHint = "Нажми на замок, соседний город или лагерь разбойников, чтобы напасть. Карту можно двигать и приближать";
const float CastleSize = 2.2f;
const float CitySize = 1.1f;
const float CampSize = 0.8f;
const float StartZoom = 8f;
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f);
static readonly Color PlayerCityColor = new Color(0.25f, 0.5f, 0.95f);
static readonly Color NeighborCityColor = new Color(0.75f, 0.55f, 0.3f);
static readonly Color CampColor = new Color(0.45f, 0.2f, 0.45f);
static readonly Color CampClearedColor = new Color(0.35f, 0.35f, 0.35f, 0.6f);
private Transform mapRoot;
private readonly Dictionary<int, MarchView> marchViews = new Dictionary<int, MarchView>();
@@ -60,25 +63,53 @@ 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;
void Start()
{
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);
@@ -93,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;
}
@@ -100,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;
}
@@ -107,8 +142,65 @@ public class WorldMapController : MonoBehaviour
{
HandleMapTap();
SyncMarchViews();
RefreshCamps();
RefreshOpenAttackPanel();
soldiersText.text = $"Войска дома: {GetSoldiersAtHome()} В походе: {World.Marches.Count}";
// Воины, а не отряды: "В походе: 1" читалось как один воин
int inMarches = 0, garrison = 0;
if (GameServices.TryGet(out IArmy army)) { inMarches = army.TroopsInMarches; garrison = army.GarrisonTotal; }
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)
@@ -135,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:
@@ -147,7 +239,14 @@ 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:
// Круг, а не квадрат: лагерь не путается с городом
var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0);
var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f);
campViews.Add((node, sprite, label));
break;
}
}
@@ -187,7 +286,7 @@ public class WorldMapController : MonoBehaviour
return sr;
}
void CreateLabel(string text, Vector3 position, float fontSize)
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(mapRoot, false);
@@ -198,6 +297,91 @@ public class WorldMapController : MonoBehaviour
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
tmp.sortingOrder = 10;
return tmp;
}
void RefreshCamps()
{
if (Time.unscaledTime < nextCampRefresh) return;
nextCampRefresh = Time.unscaledTime + 1f;
long now = MarchManager.NowMs;
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 // разбил его сам игрок -- это он знает
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
: $"{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;
return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек";
}
public static Vector3 ToWorld(MapNode node)
@@ -211,12 +395,17 @@ 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);
if (node == null) return;
if (node.type == MapNodeType.PlayerCity) hintText.text = "Это твой город. Нажми НАЗАД, чтобы вернуться в него";
else if (node.type == MapNodeType.Camp && World.IsCampCleared(node.id, MarchManager.NowMs))
hintText.text = $"{node.displayName} уже разбит. Появится снова через {FormatMinutes(World.CampRespawnInMs(node.id, MarchManager.NowMs))}";
else if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.occupiedBy != 0)
hintText.text = $"{node.displayName}: идёт сбор. Войско вернётся само, когда груз будет полон или точка истощится";
else if (node.canBeAttacked) OpenAttackPanel(node);
}
@@ -224,8 +413,11 @@ public class WorldMapController : MonoBehaviour
{
foreach (var node in World.Nodes)
{
if (node.type == MapNodeType.ResourcePoint && World.GetPoint(node.id)?.active != true) continue; // исчезнувшая точка
// Зона нажатия чуть больше самого квадрата -- по мелким городам легче попасть пальцем
float halfSize = (node.type == MapNodeType.BossCastle ? CastleSize : CitySize) / 2f + 0.2f;
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;
}
@@ -238,33 +430,190 @@ 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}"
attackTitleText.text = target.type == MapNodeType.NeighborCity ? $"Город игрока {target.displayName}"
: target.type == MapNodeType.Camp ? $"{target.displayName}, ур. {target.level}"
: target.displayName;
attackInfoText.text = $"Гарнизон: {target.garrison} солдат\n" +
$"Путь в одну сторону: {travelSeconds} сек\n" +
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
attackInfoText.enableAutoSizing = true; // строка состава гарнизона длинная -- текст ужимается, а не вылезает
attackInfoText.fontSizeMin = 18;
attackInfoText.fontSizeMax = 32;
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;
soldiersSlider.maxValue = available;
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);
}
// Голод может начаться или кончиться, пока окно атаки открыто: предупреждение и прогноз
// пересчитываются, как только состояние войска изменилось (проверка -- в Update)
void RefreshAttackConditions()
{
shownConditions = MarchManager.PlayerConditions();
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();
}
void UpdateSoldiersToSendText()
{
int available = GetSoldiersAtHome();
// Две строки: сколько отправить и ожидаемый исход -- в одну строку оценка не влезала и обрезалась
soldiersToSendText.enableAutoSizing = true;
soldiersToSendText.fontSizeMin = 18;
soldiersToSendText.fontSizeMax = 32;
soldiersToSendText.overflowMode = TMPro.TextOverflowModes.Truncate;
var size = soldiersToSendText.rectTransform.sizeDelta;
if (size.y < 76) soldiersToSendText.rectTransform.sizeDelta = new Vector2(size.x, 76);
soldiersToSendText.text = available > 0
? $"Отправить: {(int)soldiersSlider.value} из {available}"
: "Дома нет солдат";
? $"Отправить: {(int)soldiersSlider.value} из {available}\n{PreviewText((int)soldiersSlider.value)}"
: GameServices.TryGet(out IArmy army) && army.GarrisonTotal > 0
? "Свободных воинов нет: все в гарнизоне (окно «Войска» в городе)"
: "Дома нет солдат";
}
// Ресурсная точка: запас, срок, добыча и груз на воина (регламент 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 "";
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
{
AttackerUnits = units,
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
Defenders = selectedTarget.garrison,
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget),
AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз
});
string chance = f.Chance == BattleChance.Advantage ? "<color=#7CD67C>преимущество</color>"
: f.Chance == BattleChance.HighRisk ? "<color=#E06A5A>высокий риск поражения</color>"
: "<color=#E8C15A>исход неясен</color>";
// Решение №11: качественный риск и рассчитанный шанс победы
return $"сила {Range(f.AttackerPowerMin, f.AttackerPowerMax)} против {Range(f.DefenderPowerMin, f.DefenderPowerMax)} -- " +
$"{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 -> обычные); одинаковые края -- одно число
static string Range(long min, long max)
{
long a = min / 100, b = max / 100;
return a == b ? a.ToString() : $"{a}-{b}";
}
// Предупреждение в окне атаки: голод ослабляет войско (план от 27.10.2026: -50%, проверяется на момент боя)
static string ConditionsWarning(BattleCondition conditions)
{
if ((conditions & BattleCondition.Famine) == 0) return "";
int percent = GameServices.Get<IBattleSystem>() is BattleSystem b ? b.ConditionPercent(conditions) : 50;
return $"\n<color=#E06A5A>Голод: войско слабее, сила {percent}%. Накормите город до боя</color>";
}
// Случайность стороны: +23 десятых процента -> "+2,3%"
static string Luck(int permille)
{
return (permille >= 0 ? "+" : "-") + (System.Math.Abs(permille) / 10f).ToString("0.#") + "%";
}
static string UnitsText(int[] units)
{
return $"пех. {units[TroopType.Infantry]}, луч. {units[TroopType.Archers]}, " +
$"кав. {units[TroopType.Cavalry]}, осад. {units[TroopType.Siege]}";
}
void CloseAttackPanel()
@@ -292,7 +641,7 @@ public class WorldMapController : MonoBehaviour
static int GetSoldiersAtHome()
{
return ResourceManager.Instance != null
? GameServices.Get<IArmy>().SoldiersAtHome // [объединение] наши воины всех типов
? GameServices.Get<IArmy>().AvailableForMarch // свободные воины всех типов: гарнизон охраняет город
: 0;
}
@@ -304,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);
@@ -335,10 +686,21 @@ public class WorldMapController : MonoBehaviour
void HandleBattleResolved(MarchData march)
{
MapNode target = World.GetNode(march.targetNodeId);
if (march.targetEmpty)
{
EnqueuePopup("ЛАГЕРЬ ПУСТ", $"{target.displayName} уже разбит\nБоя не было, домой идут все {march.survivors} воинов");
return;
}
string title = march.victory ? "ПОБЕДА" : "ПОРАЖЕНИЕ";
string body = $"{target.displayName}\n" +
$"Твоё войско: {march.soldiers}, гарнизон: {march.defenders}\n" +
$"Твои потери: {march.soldiers - march.survivors}, потери врага: {march.defenderLosses}\n" +
$"Твои потери: {march.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)}" +
((march.attackerConditions & (int)BattleCondition.Famine) != 0
? $", <color=#E06A5A>голод: сила {march.attackerConditionPercent}%</color>" : "") + "\n"
: "") +
$"Домой идут: {march.survivors}\n";
body += march.victory
? $"Добыча (придёт с войском): {MarchManager.FormatLoot(march.loot)}"
@@ -346,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);
@@ -370,6 +764,11 @@ public class WorldMapController : MonoBehaviour
var (title, body) = popupQueue.Dequeue();
popupTitleText.text = title;
popupTitleText.color = title == "ПОРАЖЕНИЕ" ? new Color(0.9f, 0.35f, 0.3f) : new Color(1f, 0.85f, 0.3f);
// Итог боя длинный (строка силы и удачи, добыча) -- текст ужимается, а не уходит под кнопку OK
popupBodyText.enableAutoSizing = true;
popupBodyText.fontSizeMin = 16;
popupBodyText.fontSizeMax = 30;
popupBodyText.overflowMode = TMPro.TextOverflowModes.Truncate;
popupBodyText.text = body;
popupPanel.SetActive(true);
}
+94 -7
View File
@@ -1,6 +1,6 @@
# Армия
Войска и их характеристики: сколько воинов каждого типа в городе, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
Войска и их характеристики: сколько воинов каждого типа в городе, сколько из них в гарнизоне, сколько в походах, обучение, потери, уход в поход и возвращение. **Единственное место, где хранятся войска.**
- **Сборка:** `Bogatyri.Army` (без Unity).
- **Папка:** `Assets/Scripts/Modules/Army`.
@@ -12,22 +12,92 @@
| --- | --- |
| `TypeCount` | Типов войск — 4 (пехота, лучники, кавалерия, осадные; порядок как в `GameData.Troops`). |
| `GetTroops(type)` | Воинов этого типа в городе. |
| `SoldiersAtHome` | Всего воинов в городе. |
| `SoldiersAtHome` | Всего воинов в городе (вместе с гарнизоном). |
| `TroopsInMarches` | Воинов в походах. |
| `GarrisonTotal`, `GetGarrison(type)` | Сколько воинов в гарнизоне — всего и по типам. |
| `MarchSlots`, `ActiveMarches` | Слоты похода (регламент 31.10): сколько походов может идти одновременно (база 2 из `MarchConfig`, «Военная логистика» — до 7, позже) и сколько идёт сейчас. `TakeForMarch` отказывает, если все слоты заняты. Походы из старых сохранений сверх лимита дойдут, новых не будет. |
| `AvailableForMarch` | Свободные воины: в городе минус гарнизон. Только их можно отправить в поход. |
| `SetGarrison(count)` | Назначить гарнизон: набирается по составу армии в городе (30 пехоты и 10 лучников, гарнизон 8 → 6 пехоты и 2 лучника). Не больше, чем воинов в городе; возвращает, сколько назначено. |
| `AddTroops(type, count)` | Добавить воинов (обучение в Казармах). |
| `LoseFraction(fraction)` | Потери в бою: от каждого типа погибает доля (с округлением вниз); возвращает, сколько погибло. |
| `TakeForMarch(marchId, count)` | Забрать воинов в поход пропорционально составу армии и запомнить состав. |
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам. |
| `TakeForMarch(marchId, count)` | Забрать свободных воинов (без гарнизона) в поход пропорционально составу и запомнить состав. `false` — ничего не взято: не хватает воинов или поход с таким номером уже есть. |
| `ReturnFromMarch(marchId, survivors)` | Вернуть выживших по тем же типам (пропорционально). `false` — поход неизвестен, никто не добавлен. |
| `GetMarchUnits(marchId)` | Состав похода по типам — для боя с типами войск. |
| `ReturnUnits(marchId, survivorUnits)` | Вернуть выживших точно по типам, как посчитал бой. Больше, чем ушло, не вернётся. `false` — поход неизвестен. |
| `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` проверяет, что «дома + в походах» всегда равно общему числу.
**Возвращение — только по известному походу и только один раз** (ревью 27.10.2026, п. 2.1):
- неизвестный поход (повторное событие, испорченное сохранение) не добавляет никого — раньше такие выжившие зачислялись пехотой;
- после возвращения запись похода удаляется, второе возвращение того же похода возвращает `false`;
- вернуться может не больше, чем ушло;
- два похода с одним номером невозможны: `TakeForMarch` отклоняет повтор, загрузка и перенос старых данных пропускают дубликаты;
- перенос старых сохранений — отдельно, в `ImportLegacy`.
**Гарнизон** — часть воинов города. Они по-прежнему в городе (`SoldiersAtHome`), но в поход не уходят:
- 30 воинов, 10 в гарнизоне → 20 свободны, всего по-прежнему 30 (тест `ArmyService_Garrison_OneSoldierInOnePlace`);
- гарнизон по каждому типу никогда не больше воинов этого типа: после потерь (`LoseFraction`) он обрезается;
- новые воины из Казарм становятся свободными, гарнизон не меняется;
- назначается в окне «Войска» в городе, карточка «Гарнизон города»: кнопки 0, -10, -1, +1, +10, «Все».
Пока гарнизон только держит воинов дома: на город игрока никто не нападает. Защита города, еда гарнизона и дебаффы при голоде — по плану ниже.
## События
- **Публикует:** `TroopsChangedEvent` (воинов в городе и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD.
- **Публикует:** `TroopsChangedEvent` (воинов в городе, из них в гарнизоне, и в походах) — при обучении, потерях, походе, возвращении, загрузке, сбросе. По нему `App/GameModules` перерисовывает панели HUD.
- **Публикует:** `TroopsTrainedEvent` (номер заказа, тип, сколько, когда были готовы) — один раз на заказ. По нему `GameProgress` ведёт статистику заданий и показывает «Обучено».
- **Слушает:** ничего.
## Сохранение
@@ -36,10 +106,18 @@
```
troops=<пехота>,<лучники>,<кавалерия>,<осадные>
garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (часть troops)
march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
train=<номер>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
trainNext=<номер следующего заказа>
wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
heal=<номер>|<тип>|<сколько>|<начало, мс>|<конец, мс>
healNext=<номер следующей группы лечения>
```
Сброс (`ResetState`): войск и походов нет.
Старое сохранение без строки `garrison` читается: гарнизона нет.
Сброс (`ResetState`): войск, гарнизона, походов, заказов обучения и раненых нет.
**Перенос из старых сохранений:**
- Раньше войска лежали в `progress.json`: `troops` хранил `GameProgress`, `sashaMarchUnits` — мост `ArmyBridge`.
@@ -62,13 +140,22 @@ march=<id похода>:<пехота>,<лучники>,<кавалерия>,<о
- общая армия = гарнизон + доступные отряды + войска в походах, на точках и в гарнизоне Круга, **один солдат не может быть в двух местах**;
- гарнизон ест еду и участвует в защите;
- при голоде дебаффы действуют и на гарнизон, и на походы.
- **Следующий шаг модуля:** гарнизон — третье место для солдата рядом с «дома» и «в походе».
- **Сделано 27.10.2026:** гарнизон города (`SetGarrison`, `AvailableForMarch`).
- **Дальше по гарнизону:** участие в защите города (когда на него смогут напасть — PvP), еда гарнизона (сейчас все воины едят одинаково, считает модуль Населения), дебаффы при голоде. Выбор состава по типам — если понадобится.
- Описания типов войск (`GameData.Troops`: цена, сила, время обучения) пока в слое приложения; цены перейдут в `EconomyConfig`.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 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`: бой с типами войск получает состав похода, выжившие возвращаются точно по типам. |
| 23.10.2026 | Claude | Войска переехали в модуль: `ArmyService` — единственное хранилище войск (секция `"army"`), `IArmy` расширен (типы, обучение, потери, войска в походах), событие `TroopsChangedEvent`. Перенос из `progress.json` с удалением старых данных только после записи `save.json`. `ArmyBridge` удалён — контракты и перенос сохранений проверены тестами. |
| 19.10.2026 | Claude | `ArmyBridge.TroopsInMarches` для панели «Войска». После удаления маленькой карты войска в походах учитываются только здесь (`sashaMarchUnits`). |
| 19.10.2026 | Claude | Модуль создан: `ArmyMath` (правила деления войск, без Unity, тесты на отсутствие двойного учёта). `ArmyBridge` сохранён и теперь реализует `IArmy` через `ArmyMath`; формат `sashaMarchUnits` не изменён. |
+102 -13
View File
@@ -1,28 +1,92 @@
# Боевая система
Урон, потери, исход боя. Модуль только считает: кто победил и сколько погибло. Кто идёт в бой и что делать с итогом, решают другие модули.
Урон, потери, исход боя с учётом типов войск. Модуль только считает: кто победил и сколько погибло каждого типа. Кто идёт в бой и что делать с итогом, решают другие модули.
- **Сборка:** `Bogatyri.Battle` (без Unity — тот же код будет считать бои на сервере).
- **Сборка:** `Bogatyri.Battle` (без Unity и без float — тот же код будет считать бои на сервере).
- **Папка:** `Assets/Scripts/Modules/Battle`.
- **Зависит от:** ядра.
## Контракт `IBattleSystem`
`BattleOutcome Resolve(BattleRequest request)`
- `BattleOutcome Resolve(BattleRequest request)` — бой.
- `BattleForecast Forecast(BattleRequest request)` — прогноз до боя с учётом случайности. Итог конкретного боя не раскрывает.
| `BattleRequest` | `BattleOutcome` |
| `BattleRequest` | Что это |
| --- | --- |
| `Attackers` — атакующих | `Victory` — победил атакующий |
| `Defenders` — защитников | `AttackerLosses`, `DefenderLosses`, `Survivors` |
| `AttackerUnits`, `DefenderUnits` | Состав сторон по типам (`TroopType`: пехота, лучники, кавалерия, осадные). |
| `Attackers`, `Defenders` | Число воинов, если состав не задан (`null`) — тогда все считаются пехотой. |
| `DefenderFortified` | Защитники за стенами (город, замок). У лагерей разбойников стен нет — бой в поле. |
| `AttackerBonusPercent`, `DefenderBonusPercent` | Бонусы сторон в процентах: герой, технологии, голод (минус). |
| `AttackerConditions`, `DefenderConditions` | Состояния сторон (`BattleCondition`, флаги): `Famine` — голод, `Fatigue` — усталость. |
| `Seed` | Номер боя. Из него считается случайность сторон: тот же номер с теми же войсками — всегда тот же итог. |
Регистрирует `App/GameModules`. Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
| `BattleOutcome` | Что это |
| --- | --- |
| `Victory` | Победил атакующий. |
| `BattleId`, `RulesVersion` | Номер боя (= `Seed`) и версия правил, по которой посчитан итог. |
| `AttackerPower`, `DefenderPower` | Итоговая сила сторон ×100 (целые числа): с контрами, стенами, бонусами и случайностью. |
| `AttackerLuckPermille`, `DefenderLuckPermille` | Выпавшая случайность сторон в десятых долях процента: +23 = +2,3%. |
| `AttackerConditionPercent`, `DefenderConditionPercent` | Сколько силы оставили состояния: 100 — без штрафов, 50 — голод, 40 — голод и усталость. |
| `AttackerLosses`, `DefenderLosses`, `Survivors` | Выбывшие (погибшие + раненые) и выжившие атакующие — всего. |
| `…UnitsBefore`, `…SurvivorUnits`, `…DeadUnits`, `…WoundedUnits` | По типам войск и **для обеих сторон**: до боя, выжившие, погибшие, раненые. По каждому типу: до = выжившие + погибшие + раненые. Доля погибших среди выбывших — от соотношения сил своей стороны (см. «Погибшие и раненые»). |
| `DefenderLossUnits` | Выбывшие защитники по типам — для старого кода. |
## Текущие правила
| `BattleForecast` | Что это |
| --- | --- |
| `Chance` | `Advantage` — победа при любой случайности, `Uncertain` — решает случайность, `HighRisk` — поражение при любой случайности. |
| `WinChancePermille` | Шанс победы нападающего в десятых долях процента (решение №11). Точный: перебираются все 101 × 101 равновероятных сочетаний удачи сторон. |
| `AttackerPowerMin/Max`, `DefenderPowerMin/Max` | Сила сторон при крайних значениях случайности. |
| `AttackerLossesMin/Max` | Потери атакующего: от лучшего случая до худшего, с учётом округления потерь (№13). |
- Побеждает тот, у кого больше солдат; ничья — победа защитника.
- Проигравший теряет половину своего войска, победитель — столько же.
Регистрирует `App/GameModules`: числа боя — из таблицы `BattleConfig`, сила воина каждого типа — из `GameData.Troops` (4 / 5 / 9 / 16). Мир получает модуль при создании симуляции, не через реестр — так симуляцию можно запустить на сервере.
Пример: 30 против 20 — победа, погибает 10 атакующих и 10 защитников, домой идут 20.
## Правила
Числа — **стартовые для тестирования** (план от 27.10.2026). Все они в одном месте:
- **в игре** — таблица `Assets/Resources/BattleConfig.asset`: выдели её в окне Project и меняй числа в Inspector, изменения действуют со следующего запуска. Скрипт таблицы — `App/BattleConfig.cs`;
- **в модуле** — `BattleRules.cs` (значения по умолчанию для тестов и сервера). `App/GameModules` переводит таблицу в `BattleRules`;
- **сила воинов** — в `GameData.Troops`, рядом с ценой и временем обучения;
- **поменял числа — подними `version`**: итог боя запоминает версию правил (`RulesVersion`).
| Правило | Значение |
| --- | --- |
| Сила стороны | Сумма по типам: воины × сила типа × (100% + контры + осадный бонус) × (100% + бонусы стороны). |
| Контры, +25% | Пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты. Бонус взвешивается по составу врага: против армии, где кавалерии 20%, пехота получает +5%. |
| Осадные | Против стен +50%, в поле −25%. |
| Стены | Защитники за стенами сильнее на 20%. |
| Удача, ±5% | Решение №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 пехоты | поражение | 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 пехоты | высокий риск поражения | 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 |
Шанс проверен тестом `Battle_WinChance_MatchesRealBattles`: совпадает с долей побед в 20 000 настоящих боёв с точностью ±2%.
Бой без состава (старые походы, `Attackers` и `Defenders` числами) считается так, будто все — пехота. Без стен 30 против 20 (1,5:1) — победа, возвращаются 18–19.
## События
@@ -34,11 +98,36 @@
## Решения и план
- **Этап 6:** формула с типами войск — кто кого контрит и как делятся потери. Сначала записать правила в документ экономики.
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. В `BattleRequest` для этого появятся модификаторы силы сторон; проценты — из `EconomyConfig`.
- **Сделано 28.10.2026 (этап 2 плана):** числа — в таблицу `BattleConfig` с версией; итог боя симметричный для обеих сторон; случайность ±5% из номера боя; прогноз «преимущество / исход неясен / высокий риск поражения».
- **Решено комплектом 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 плана.
- **Малые отряды** решены (№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` — например, через новый контракт или поле похода.
- **Решение команды от 19.10.2026:** при голоде сильные дебаффы на боевую эффективность, включая гарнизон и походы. Для этого есть `AttackerBonusPercent` / `DefenderBonusPercent` (минус), проценты — из `EconomyConfig`. Голод уже считает модуль Населения (`IsStarving`).
- **Гарнизоны соседей** пока не уменьшаются после боя: соседи — заглушки до сервера.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 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%, бой числом. Проверяются исход и арифметика (нет отрицательных, выжившие + погибшие = ушедшие). Правило округления потерь — открытый вопрос. |
| 27.10.2026 | Claude | Бой с типами войск: сила по типам из `GameData.Troops`, контры (пехота > кавалерия > лучники > пехота, +25%), стены +20%, осадные +50% против стен и −25% в поле, потери по типам. `BattleRequest` и `BattleOutcome` расширены, старый бой «числом» считается пехотой. Тесты: контры, стены, осадные, потери в пределах состава. |
| 19.10.2026 | Claude | Модуль выделен: `BattleResolver` из `WorldSimulation.cs` стал `BattleSystem` с контрактом `IBattleSystem`; `BattleOutcome` перенесён в ядро. Правила не изменились. |
+26 -3
View File
@@ -31,10 +31,14 @@
| `IProduction` | Экономика (`ProductionService`) | Места для рабочих по профессиям, эффективность, добыча в час; `Produce` — добыть за время. |
| `ISettlement` | Поселение (`SettlementModule`) | Уровень Ратуши, число построек, открыт ли город. |
| `ILeveledBuilding` | Здания с уровнем (`ResourceCollector`) | Уровень здания — его сохраняет Поселение. |
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод; `Tick` — досчитать до момента. |
| `IPopulation` | Население (`PopulationService`) | Жители, рабочие по профессиям, воины, предел жилья, расход еды и прирост в час, голод и его история (`WasStarvingAt`), найм воинов из жителей; `Tick` — досчитать до момента. |
| `IArmy` | Армия (`ArmyService`) | Войска по типам, в городе и в походах; обучение, потери; забрать в поход, вернуть выживших. |
| `IWorld` | Мир (`MarchManager`) | Сколько походов в пути; начать поход. |
| `IBattleSystem` | Бой (`BattleSystem`) | Посчитать исход боя по `BattleRequest`. |
| `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`) | Круг игрока, список Кругов; основать, вступить, выйти. |
| `ISaveSystem` | Сохранения (`SaveManager`) | Есть ли сохранение; сохранить сейчас; стереть; сбросить всё (`ResetAll`). |
@@ -58,6 +62,25 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 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`. |
| 28.10.2026 | Claude | `IArmy.ReturnFromMarch` и `IArmy.ReturnUnits` возвращают `bool`: зачисление только по известному походу и один раз. `TakeForMarch` отклоняет повторный номер похода. |
| 27.10.2026 | Claude | `IArmy`: гарнизон (`GarrisonTotal`, `GetGarrison`, `AvailableForMarch`, `SetGarrison`); `TroopsChangedEvent.Garrison`. |
| 27.10.2026 | Claude | `MarchReturnedEvent.TargetEmpty`: войско пришло к уже разбитому лагерю. |
| 27.10.2026 | Claude | `TroopType` (типы войск), `BattleRequest` / `BattleOutcome` с составом по типам, силой сторон и бонусами; `IArmy.GetMarchUnits`, `IArmy.ReturnUnits`. |
| 27.10.2026 | Claude | `WorkJob`; контракт `IProduction`; `IEconomy.GetCapacity`; `IPopulation`: `Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`; событие `WorkersChangedEvent`. |
| 26.10.2026 | Claude | `GameClock`; контракт `IPopulation`; события `GameTimeAdvancedEvent`, `PopulationChangedEvent`, `StarvationChangedEvent`, `NewGameStartedEvent`. |
| 23.10.2026 | Claude | `IArmy` расширен (типы, `AddTroops`, `LoseFraction`, `TroopsInMarches`), событие `TroopsChangedEvent`. `GameLoadedEvent` публикуется и когда файла сохранения нет. |
+7 -1
View File
@@ -43,13 +43,15 @@
## Добыча через рабочих (этап 3)
- **Ресурс производят люди, здание даёт места и эффективность.** Добыча в час = min(рабочие, места) × норма × эффективность.
- **Голод** (регламент 31.10, ТЕСТ): пока город голодает (`IPopulation.IsStarving`), добыча всего, кроме еды, ×0.5 (`EconomyConfig`, `famineNonFoodMultiplier`). Еда добывается без штрафа, штраф снимается сразу, как только город снова сыт.
- **Нормы T1 в час:** лесоруб — 2 дерева, фермер — 2 еды, каменотёс — 1.5 камня, шахтёр — 1 металла (Шахта) или 0.4 золота (Золотой рудник).
- **Здание 1 уровня — 30 мест.** Каждый следующий уровень: +10 мест и +10% к норме. Не утверждено.
- **Кликер убран.** `ResourceCollector` теперь здание-рабочее место (имя класса оставлено, чтобы не перенастраивать префабы). Тап по зданию — окно здания и рабочих (`App/BuildingWindow`): `−` / `+` на каждую профессию, «Свободно: N из M», кнопка «Улучшить». `ResourceProducer` (добыча по таймеру) выключен.
- **Дом** (здание «Население», `resourceType = Population`) — не рабочее место: каждый уровень +50 к пределу жилья.
- **Кристалл** — рабочих нет, синий кристалл добывается в разломах на карте мира (этап 6).
- **Какие здания стоят,** модулю сообщает `App/CitySync`: раз в полсекунды смотрит город и передаёт места, Дома и уровни Складов. Модуль запоминает места в сохранении, поэтому добыча идёт и на карте мира, и офлайн.
- **Новые места заполняются сами:** построили или улучшили здание — свободные рабочие встают туда. Старое сохранение при первом запуске — рабочие расставляются поровну по всем местам.
- **Новые места сами не заполняются** (регламент 31.10): построили или улучшили здание — места пустые, пока игрок не поставит рабочих в окне здания. Автоматически — только стартовая расстановка один раз (новая игра или первая загрузка старого сохранения): до 120 рабочих поровну по местам.
- **Голод в офлайне** считается по тем же шагам, что и в игре: Население досчитывает время кусками по 10 минут, и в каждом куске добыча берёт голод на этот момент.
- **Проверка из документа** (тест `BalancedCity_…`): 45 фермеров, 25 лесорубов, 15 каменотёсов, 15 шахтёров → 90 еды, 50 дерева, 22.5 камня, 15 металла в час.
## События
@@ -107,6 +109,10 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 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` «Люди» на ячейках панели. |
| 26.10.2026 | Claude | Таблица баланса `EconomyConfig`. Стартовые ресурсы новой игры. Ресурс «Люди» отражает модуль Населения. |
| 19.10.2026 | Claude | `ResetState()`: при сбросе сохранений ресурсы возвращаются к стартовым. |
+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`). |
+38 -11
View File
@@ -1,22 +1,26 @@
# Население
Сколько в городе людей, сколько они едят и как прирастают. Этап 2 плана разработки.
Люди города: жители, рабочие, прирост, еда, голод, найм в войско и демографические ограничения. Самостоятельный модуль (регламент 28.10 и 31.10.2026). С Поселением (здания, Ратуша) связан только через контракт ядра `ISettlement`. Этап 2 плана разработки.
- **Ответственный:** Валдемар.
- **Сборка:** `Bogatyri.Population` (без Unity).
- **Папка:** `Assets/Scripts/Modules/Population`.
- **Зависит от:** ядра. Еду берёт через `IEconomy`, воинов считает через `IArmy`, уровень Ратуши — через `ISettlement`.
## Как устроено
- **Люди города = жители + воины.** Жителей хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
- **Жители:** способные работать (`Workforce` — 120 из 270 в стартовом городе, доля сохраняется при росте) и остальные. Способные работать стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота) или свободны (`FreeWorkers`).
- **Рабочих ставят** окно здания (`SetWorkers`) и `App/CitySync` (новые места заполняются свободными сами). Больше, чем мест в зданиях (`IProduction.Slots`) и свободных, не поставить.
- **Люди города = жители + новобранцы в Казармах + воины.** Жителей и новобранцев хранит этот модуль, воинов — Армия. Так один человек не записан в двух местах.
- **Жители:** работать может любой житель — общего ограничения нет (регламент 31.10). Рабочих ограничивают свободные жители и рабочие места в зданиях. Рабочие стоят на профессиях (`WorkJob`: лесорубы, фермеры, каменотёсы, шахтёры металла и золота), остальные свободны. Стартовый город: 120 рабочих из 270 жителей — при первой расстановке (`AutoFillAll`) больше `StartWorkers` (120) не ставится.
- **Найм воинов** (решение команды от 19.10.2026). Воины набираются из свободных жителей (не на работе), гражданский резерв не отдаётся (`Recruitable` = свободные − резерв). Пример команды: 150 свободных, резерв 30 — нанять можно не больше 120. Воин стоит `PeoplePerSoldier` жителей (1). Человек уходит из жителей в начале обучения (`TryRecruit`), учится в Казармах (`InTraining`, ест как воин) и после обучения переходит в Армию (`RecruitsTrained`). Погибшие в бою воины уменьшают население — их больше нет в Армии. Резерв зависит от уровня Ратуши (регламент 31.10, ТЕСТ): уровни 1–2 — 10, 3–5 — 20, 6–10 — 30 (`EconomyConfig`, `civilianReserveByTownHallLevel`).
- **Группы людей не пересекаются** (регламент 31.10). Каждый человек ровно в одной группе: свободный житель (`Idle`), рабочий (`Workers`), новобранец (`InTraining`), воин (`Soldiers`, хранит Армия). `Total` — их сумма. На работу ставятся только свободные жители, в войско берутся только свободные жители сверх резерва.
- **Рабочих ставит игрок** в окне здания (`SetWorkers`): не больше, чем мест в зданиях (`IProduction.Slots`) и свободных жителей. Автоматически места заполняются только один раз — стартовая расстановка (`AutoFillAll`, до 120 рабочих) в новой игре или при первой загрузке старого сохранения. Новые места после постройки или улучшения остаются пустыми, пока игрок не поставит рабочих. Мест стало меньше — лишних рабочих снимают (`ClampJobs`).
- **Еда в час:** 0.1 на жителя, 0.2 на воина. Стартовый город (270 жителей + 30 воинов) ест 33 еды в час.
- **Прирост:** +2.5 человека в час, только если еды хватило и население ниже предела жилья.
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Что ещё происходит при голоде, команда пока не решила.
- **Голод:** еды не хватило — остатки доедаются, прирост останавливается, `IsStarving = true`. Добыча всего, кроме еды, ×0.5 (модуль Экономики, регламент 31.10, ТЕСТ), еда добывается без штрафа. Бой ослабляет голодное войско (модуль Боя, −50%). Штраф снимается сразу, как только город снова сыт. Смерти от голода нет.
- **История голода:** модуль помнит отрезки голода за последние `MaxOfflineHours` (48 ч) с точностью до шага расчёта (10 минут). Нужна Бою: поход мог дойти до цели, пока игра была закрыта, и голод берётся на момент боя (`WasStarvingAt`), а не на момент входа в игру.
- **Предел жилья:** `HousingBase + HousingPerTownHallLevel × (уровень Ратуши − 1) + HousingPerHouseLevel × уровни Домов` = 300 + 50 + 50. По решению команды от 19.10 стартовый город (300) упирается в предел: прирост пойдёт после постройки Дома. Прибавки не утверждены.
- **Добыча** идёт внутри расчёта населения: в каждом куске сначала `IProduction.Produce`, потом еда, потом прирост. Иначе офлайн город «голодал» бы, хотя фермы работали.
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, расчёт просто продолжается с «сейчас».
- **Время** — только из `GameClock`. Расчёт идёт кусками по 10 минут, и в игре, и офлайн, и при перемотке — правила одни. Офлайн досчитывается не больше чем за `MaxOfflineHours`. Если часы ушли назад, ничего не считается, а метка последнего расчёта назад **не** уходит: когда часы снова дойдут до уже посчитанного, второй раз оно не начислится (решения 04.11). В начале каждого шага и в конце `Tick` модуль публикует `GameTimeSimulatedEvent` — до какого момента дошёл расчёт; по нему стройка, закончившаяся офлайн, вступает в силу ровно в свой момент (`App/ConstructionClock`).
- **Еда списывается целыми единицами,** доли копятся в модуле: склад хранит `float`, и частые мелкие списания давали бы ошибку округления.
## Контракт `IPopulation`
@@ -27,10 +31,16 @@
| `Civilians`, `Workers`, `Idle` | Жители, из них на работе и остальные. |
| `Workforce`, `FreeWorkers` | Сколько могут работать; сколько из них без работы. |
| `GetWorkers(job)`, `SetWorkers(job, n)` | Рабочие профессии; поставить n (в пределах мест и свободных). |
| `Soldiers` | Воины — из модуля Армии. |
| `Soldiers` | Воины — из модуля Армии: в городе, в походах и раненые (решения 31.10, п. 7: раненые — тоже люди). |
| `HousingCap` | Предел населения. |
| `FoodPerHour`, `GrowthPerHour` | Расход еды и прирост в час сейчас. |
| `IsStarving` | Еды не хватило на последний расчёт. |
| `CivilianReserve` | Гражданский резерв: столько свободных жителей в войско не взять. |
| `Recruitable` | Сколько воинов можно нанять прямо сейчас: (свободные − резерв) / жителей на воина. |
| `InTraining` | Людей, взятых из жителей и ещё обучающихся в Казармах. |
| `TryRecruit(n)`, `RecruitsTrained(n)` | Забрать людей в Казармы в начале обучения; обучение закончено — их считает Армия. |
| `CompleteTraining(type, n)` | Передать n новобранцев в Армию одним действием: люди уходят из Населения, только если Армия их приняла. Иначе ничего не меняется — никто не теряется и не дублируется. Так заканчивают обучение Казармы (`GameProgress`). |
| `WasStarvingAt(unixTime)` | Голодал ли город в этот момент (секунды Unix, время `GameClock`). Позже последнего расчёта -- как сейчас; старше 48 часов -- нет. |
| `Tick(unixNow)` | Досчитать население до момента `unixNow`. |
Получить: `GameServices.Get<IPopulation>()`.
@@ -48,7 +58,7 @@
## Сохранение
Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `kit=`.
Секция `"population"`, простые строки: `civilians=`, `jobs=` (рабочие по профессиям), `jobsInit=`, `houses=`, `growth=`, `food=`, `lastTick=`, `starving=`, `training=` (новобранцы в Казармах), `famine=` (история голода: `начало-конец;начало-конец`, конец 0 -- голод идёт), `kit=`.
Сброс (`ResetState`): стартовый город, 270 жителей. Стартовое войско (30 пехоты) и стартовые ресурсы выдаёт `App/GameModules` после сброса и при первой игре (`NewGameStartedEvent`) — один раз (`kit=1`). Старым сохранениям без секции населения стартовый набор не выдаётся.
@@ -57,6 +67,8 @@
| Файл | Что делает |
| --- | --- |
| `App/GameModules.cs` | Создаёт модуль с числами из `EconomyConfig`, подписывает на события, выдаёт стартовый набор, держит ресурс «Люди» на верхней панели равным `Total`. |
| `Meta/Core/GameProgress.cs` | Казармы: перед обучением проверяет `Recruitable`, забирает людей (`TryRecruit`), после обучения — `RecruitsTrained`, потом воины в Армию. |
| `Meta/Panels/TroopsPanel.cs` | Панель «Войска»: «Обучаются в Казармах», «Можно нанять из жителей (резерв N)»; кнопки найма серые, если жителей не хватает. |
| `App/PopulationTicker.cs` | Раз в 2 секунды вызывает `Tick` (модуль без Unity и не знает про `Update`). |
| `App/DebugPanel.cs` | Кнопка «DEV»: «+1 час», «+10 часов», ресурсы, «Еда = 0». Только редактор и Development Build. |
@@ -76,22 +88,37 @@
- упёрлись в предел — прирост стоит, Ратуша выше — идёт; предел не превышается;
- офлайн ограничен 48 часами;
- частые расчёты дают то же, что один большой;
- часы назад — ничего не ломается;
- часы назад — ничего не ломается, уже посчитанное время не начисляется второй раз;
- перемотка идёт через те же правила;
- сохранение;
- стартовый набор — один раз;
- сброс.
- сброс;
- найм: 150 свободных, резерв 30 (Ратуша 6) → не больше 120; стартовый город — резерв 10; резерв по уровням Ратуши 10/20/30; стартовая расстановка — 120 рабочих; людей столько же, новобранцы не считаются дважды; рабочие остаются на работе; новобранцы едят как воины и сохраняются; старый заказ обучения не уводит в минус;
- история голода: бой офлайн видит голод на свой момент, история сохраняется, сбрасывается и забывается через 48 часов.
## Решения и план
- **Голод:** дебаффы (в том числе у защитников) и правило снятия — решение команды от 19.10.2026, числа не утверждены. Модуль уже сообщает `StarvationChangedEvent`.
- **Предел жилья:** по решению команды растёт с постройкой Дома — сделано (здание «Население»).
- **Этап 3:** сделано 27.10.2026 — рабочие по профессиям, Дома, добыча в общем расчёте.
- **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026).
- **Вопросы Саши от 28.10.2026 (отчёт по военной части):**
- п. 7, история голода -- сделано (`WasStarvingAt`). Бою нужно перейти с `IsStarving` на `WasStarvingAt(время боя)`. Походы идут по реальному времени, Население -- по `GameClock`: после перемотки в DEV-панели они расходятся.
- п. 8, норма еды на солдата -- сейчас 0.2 еды в час реального времени, игрового календаря нет. Предложение: не вводить; если нужен «игровой месяц» -- 1 месяц = 1 сутки, норма ≈ 5 еды. Решает Дима.
- **Этап 5:** найм воина забирает человека из жителей (гражданский резерв — решение от 19.10.2026) — сделано 28.10.2026. Цену воина в ресурсах (`EconomyConfig`, раздел «Солдат»: 15 еды, 5 металла, 5 золота) Казармы пока не используют — у них своя цена в `GameData.Troops`.
- **Схема модулей:** Население — самостоятельный модуль (регламент 28.10 и 31.10.2026). Перенос внутрь Поселения от 28.10 был ошибкой и откатан 31.10.
- **Открыто (регламент 31.10, ждёт решения):** нормы еды для четырёх групп (гражданские и рабочие, новобранцы, солдаты, раненые); переход экономики на игровое время (1 игровой час = 5 реальных минут) и единицы офлайна (48 ч) и истории голода; кто считает предел жилья и рабочие места (по регламенту — Поселение); недоступен ли резерв и для работы.
## Журнал изменений
| Дата | Кто | Что |
| --- | --- | --- |
| 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 (ТЕСТ). Тесты. |
| 28.10.2026 | Claude | Население стало частью Поселения: папка `Modules/Settlement/Population`, сборка `Bogatyri.Settlement.Population`. Найм воинов из свободных жителей с гражданским резервом (`CivilianReserve`, `Recruitable`, `InTraining`, `TryRecruit`, `RecruitsTrained`), строка `training=`, `Workforce` — 40% всех людей. Тесты. |
| 28.10.2026 | Claude | История голода: `IPopulation.WasStarvingAt`, строка `famine=` в сохранении, тесты (вопрос Саши п. 7). |
| 27.10.2026 | Claude | Рабочие по профессиям (`Workforce`, `FreeWorkers`, `GetWorkers`, `SetWorkers`, автозаполнение), Дома в пределе жилья, базовый предел 300 (решение 19.10), добыча в общем расчёте. |
| 26.10.2026 | Claude | Создан модуль: жители, еда, прирост, голод, предел жилья, офлайн-прогресс, секция `population`, тесты. |

Some files were not shown because too many files have changed in this diff Show More