Author SHA1 Message Date
AlexanderandClaude Opus 5.5 0ebbf56306 Синие кристаллы (регламент 05.11, пп. 1 и 3): не сверх 50 и без потерь, ожидание наград (не больше 2), задания -- 1-2 кристалла
- CrystalRewards (без Unity) + 6 тестов из раздела 6 регламента
- Задания: сколько помещается, остаток ждёт в задании («Забрать»); третья не помещающаяся награда не выдаётся
- Остальные награды: не поместившееся -- письмом «Кристаллы ждут места», из письма -- сколько помещается
- Награды заданий 1-2 вместо 20-250, ежедневные -- без кристаллов

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:29:31 +01:00
AlexanderandClaude Opus 5.5 f78eb7ea7e Перенос 2D → 3D без потерь (расчистка места, очередь «ждут места»); DEV «Камера» -- ползунки наклона, перспективы и отдаления
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:56:38 +01:00
AlexanderandClaude Opus 5.5 8a5f7253db DEV: «Кристаллы = макс» -- до предела склада, предел не меняется
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:51:19 +01:00
AlexanderandClaude Opus 5.5 a6b5926e39 DEV: «+1000 кристаллов» сверх предела (только для теста)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:50:46 +01:00
AlexanderandClaude Opus 5.5 aa7789dc97 DEV: «+1000 всех ресурсов» вместо +100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:49:55 +01:00
AlexanderandClaude Opus 5.5 422f55f22e Стройка не висит на 00:00 после «Сбросить перемотку»: расчёт сообщает текущее время и при часах назад (без начисления)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:45:12 +01:00
AlexanderandClaude Opus 5.5 65f5039f74 Только 3D-поселение (решение 05.11): Золотой рудник, Кристалл и Терем в 3D, перенос 2D-города, перспективная камера, MVP1 вне сборки
- Золотой рудник, Кристалл, Терем: цены, уровни Ратуши и компоненты как в 2D
- Перенос: нет 3D-города в сохранении, есть 2D -- Ратуша с уровнем и улучшением, здания с уровнями и стройками; секция settlement не меняется
- Камера перспективная (City3DCamera.perspective, поле зрения 40 -- ТЕСТ)
- Город -- City3D; MVP1 выключена в Build Settings, код и сцена 2D остаются
- Меню стройки в два столбца

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:31:25 +01:00
AlexanderandClaude Opus 5.5 4fe2dced76 Окно здания: строка «Цена» обновляется вместе с кнопкой; тестовые кнопки 3D-сцены не рисуются поверх окон
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:27:58 +01:00
AlexanderandClaude Opus 5.5 d7348dd23d Чат: на телефоне после «Отправить» клавиатура закрывается полностью, без мигания
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:17:53 +01:00
AlexanderandClaude Opus 5.5 7d932e3cbc 3D: приближение работает и с ортографической камерой (размер кадра по расстоянию)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:14:38 +01:00
AlexanderandClaude Opus 5.5 35319be8ca 3D: дымка идёт за камерой (отдаление больше не «сереет»); «Улучшить» оживает, когда ресурсов стало хватать
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:12:10 +01:00
AlexanderandClaude Opus 5.5 5502489c07 3D: стройка видна (серое здание + «Стройка 0:45»), «Войска» знают о строящихся Казармах, кнопки зума +/−
- BuildTimer затемнял только 2D-спрайты: в 3D строящиеся Казармы выглядели готовыми, а окно «Войска» писало «казарм: 0»
- «Войска»: «Казармы строятся, осталось …»; перерисовка, когда достроились; «Построить Казармы» в 3D ставит призрак
- «+ / −» справа: в Device Simulator колесо мыши не доходит до игры

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:05:59 +01:00
AlexanderandClaude Opus 5.5 f307c83352 3D: нажатие «по HUD» -- только по кнопкам, прокрутке и небольшим панелям; невидимый слой во весь экран не блокирует карту
Тап, перетаскивание, стройка и зум в 3D-поселении блокировались проверкой UIBlocker. Такой слой и открытая
панель HUD пишутся в Console один раз -- чтобы найти виновника.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:58:12 +01:00
AlexanderandClaude Opus 5.5 c29a4ae708 3D: призрак нового здания -- на ближайшем свободном месте, а не на Ратуше в центре камеры
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:56:44 +01:00
AlexanderandClaude Opus 5.5 56b66af0c3 Интеграция 3D: сохранение в первом кадре не затирает 3D-город; Ратуша на карте мира -- из последнего открытого города
CaptureState до ApplySaved отдаёт загруженное состояние (поход, вернувшийся офлайн, сохранял пустой город).
ISettlement.TownHallLevel вне города -- уровень Ратуши последнего открытого города (2D или 3D).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:22:23 +01:00
AlexanderandClaude Opus 5.5 a03098ffe0 3D: обычный 3D-рендерер URP в репозитории (Renderer3D + список рендереров)
Раньше он создавался только локально командой меню, и в свежем клоне или APK 3D-камеры
рисовали 2D-рендерером -- без света и теней. Map3DLit.mat -- пересохранён Unity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:14:48 +01:00
vologdia 413c627d5c 3D: камера как на карте мира, расчистка только в зоне, почта под Наградами, старт с City3D 2026-11-05 17:46:42 +03:00
vologdia e26eeaffaf 3D: версия на экране, отладочная сетка, сборка APK одной кнопкой и починка сборки Android 2026-11-05 15:12:19 +03:00
vologdia 88f460a4a6 3D: общий HUD, здания игры в 3D (окна, рабочие, улучшения, стройка по таймеру, сохранение), окна зданий с прокруткой 2026-11-05 14:18:22 +03:00
vologdia 235ff124ee Merge branch 'feature/troop-cards' into test/3d-together 2026-11-05 12:00:17 +03:00
vologdia bb3baba3b7 Исправление: атрибут RuntimeInitializeOnLoad вернулся к MigrateLegacyMarches 2026-11-05 11:54:20 +03:00
vologdia 1f0676ffc9 Merge remote-tracking branch 'origin/feature/3d-settlement' into test/3d-together
# Conflicts:
#	Docs/Modules/Core.md
#	Docs/Modules/Population.md
#	Docs/Modules/README.md
#	Docs/Modules/Settlement.md
2026-11-05 11:47:41 +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 61ba46947c Разведка М2: целенаправленная разведка поселений, замка и лагерей с отчётами II и III
Отряд идёт маршем, наблюдает 2/4 (поселение, лагерь) или 3/6 (замок) игровых часов, отчёт на конец наблюдения,
потом домой. Уровень II -- точные войска, герой, стены; III -- плюс защита и ресурсы ±15%. Один отчёт на операцию,
сохранение. Карта: кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту вне обзора.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:21:54 +01:00
AlexanderandClaude Opus 5.5 6c39c8b102 Пропавшие без вести (решения 01.11, п. 5)
Бой: отдельный статус среди выбывших (доля не утверждена -- 0, выключено), версия правил 8.
Армия: пропавшие из похода -- в группу; окно 6 игровых часов после обратного пути, возвращение 2-4 группами,
70% здоровыми / 30% ранеными, остальные списываются; сохранение без повторного возвращения.
Население: занимают места, но не едят. Окна: сколько пропало и сколько ещё может вернуться.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:16:43 +01:00
AlexanderandClaude Opus 5.5 cfbdfd9876 Еда воинов и раненых по типам (решения 01.11, п. 4): 0,15 / 0,20 / 0,15 / 0,15, раненые -- даже в ожидании койки
IArmy.GetAllTroops; PopulationRules.FoodPerTroopTypePerHour (без таблицы -- как раньше);
EconomyConfig.population.foodPerTroopTypePerHour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:13:02 +01:00
AlexanderandClaude Opus 5.5 fae42716a7 3D: земля за краем карты и участка -- вместо пустого неба (на карте тёмная, у города -- лес)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:07:28 +01:00
AlexanderandClaude Opus 5.5 e69d2bd7db 3D: свой материал на URP Lit (Resources/Map3D/Map3DLit) -- у примитивов в 2D-проекте спрайтовый шейдер без света и цвета
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:05:12 +01:00
AlexanderandClaude Opus 5.5 9a91926b2c 3D-сцены красивее: цвета, свет, небо, дымка; домики с крышами, замок, шатры, деревья, камни
Цвет -- в _BaseColor (URP), раньше всё было белым. Настройка 3D-рендерера добавляет его в URP Asset
каждого уровня качества (в проекте URP задан только в Quality) + отдельная команда меню.
Карта мира: замок со стенами и башнями, города -- домики, лагеря -- шатры с костром, кристаллы точек, рощи.
3D-поселение: здания с двускатными крышами и приметами, ели и лиственные, песчаные берега, снег на скалах.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:03:16 +01:00
AlexanderandClaude Opus 5.5 9a6e5a2e7b Карта мира в 2.5D на примитивах
Карта на земле (X/Z): замок, города, лагеря, ресурсные точки, отряды и метки разведки -- примитивы,
подписи смотрят в камеру; наклонная перспективная камера с тем же поведением (перетаскивание, щипок, тап);
туман -- в текстуре земли; солнце и 3D-рендерер URP для камеры (Map3D). Логика и окна не менялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:55:41 +01:00
AlexanderandClaude Opus 5.5 a3c4a99a79 3D-поселение, этап I: прототип на примитивах (ТЗ от 04.11)
Bogatyri.Settlement.Placement (без Unity): сетка, зоны по Ратуше, река/скалы/деревья/валуны,
отступ, уклон и фундамент, поворот 0/90/180/270, атомарный перенос с тем же id, расчистка один раз; 7 тестов.
Сцена City3D (Tools -> Bogatyri -> 3D-город): местность, призрак, перенос, расчистка, камера 2.5D,
второй рендерер URP (3D) только для её камеры -- 2D-сцены не тронуты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:50:22 +01: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
162 changed files with 31636 additions and 627 deletions
+4
View File
@@ -72,3 +72,7 @@ crashlytics-build.properties
/[Aa]ssets/[Ss]treamingAssets/aa.meta
/[Aa]ssets/[Ss]treamingAssets/aa/*
# Сборки APK (Tools -> Bogatyri -> Собрать APK) и служебные папки сборки
/Builds/
/build/
/MVP1_BurstDebugInformation_DoNotShip/
+61
View File
@@ -0,0 +1,61 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.Android;
using UnityEngine;
// Починка сборки Android на этом компьютере (05.11):
// 1. Имя пользователя Windows с русскими буквами (C:\Users\Вальдемар). Gradle хранит свои файлы в
// C:\Users\<имя>\.gradle, и одна из его программ (prefab) не понимает такой путь -- сборка падает.
// Перед сборкой переносим папку Gradle в C:\gradle (переменная GRADLE_USER_HOME для процесса Unity,
// сборщик её наследует). Ничего в Windows менять не нужно.
// 2. Два Kotlin в зависимостях (1.8 у Unity и 1.6 у одного из пакетов) -- "Duplicate class kotlin...".
// В сгенерированный проект Gradle дописываем правило: все kotlin-stdlib -- одной версии 1.8.22.
[InitializeOnLoad]
public class AndroidBuildFixes : IPostGenerateGradleAndroidProject
{
const string GradleHome = @"C:\gradle";
const string Marker = "// Bogatyri: kotlin-stdlib одной версии";
static AndroidBuildFixes() { EnsureAsciiGradleHome(); }
public static void EnsureAsciiGradleHome()
{
string current = Environment.GetEnvironmentVariable("GRADLE_USER_HOME");
if (!string.IsNullOrEmpty(current) && IsAscii(current)) return;
if (IsAscii(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))) return; // путь и так латиницей
try
{
Directory.CreateDirectory(GradleHome);
Environment.SetEnvironmentVariable("GRADLE_USER_HOME", GradleHome, EnvironmentVariableTarget.Process);
Debug.Log("[СБОРКА] Папка Gradle: " + GradleHome + " (в пути пользователя есть русские буквы)");
}
catch (Exception e) { Debug.LogWarning("[СБОРКА] Не удалось задать GRADLE_USER_HOME: " + e.Message); }
}
static bool IsAscii(string s) => !Regex.IsMatch(s ?? "", @"[^\u0000-\u007F]");
public int callbackOrder => 999;
// path -- папка unityLibrary сгенерированного проекта
public void OnPostGenerateGradleAndroidProject(string path)
{
string root = Directory.GetParent(path).FullName;
foreach (var file in new[] { Path.Combine(root, "launcher", "build.gradle"), Path.Combine(path, "build.gradle") })
{
if (!File.Exists(file)) continue;
string text = File.ReadAllText(file);
if (text.Contains(Marker)) continue;
text += "\n" + Marker + "\n" +
"configurations.all {\n" +
" resolutionStrategy.eachDependency { details ->\n" +
" if (details.requested.group == 'org.jetbrains.kotlin' && details.requested.name.startsWith('kotlin-stdlib')) {\n" +
" details.useVersion('1.8.22')\n" +
" }\n" +
" }\n" +
"}\n";
File.WriteAllText(file, text);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 030e644cb052c0f4d959e499015370fd
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.Build.Reporting;
// Сборка APK одной кнопкой (05.11): Tools -> Bogatyri -> Собрать APK.
// Файл: C:\MVP1\Builds\Bogatyri_<ветка>_v<версия>_<дата>.apk; сцены -- из списка сборки (первая -- стартовая).
// Итог сборки (успех, размер, первые ошибки) пишется в Builds\last_build.txt.
// Сборку можно запустить и файлом-запросом Builds\build_request.txt (так её запускает Claude): Unity заметит
// его в течение пары секунд, удалит и начнёт сборку.
[InitializeOnLoad]
public static class ApkBuilder
{
const string Folder = "Builds";
const string RequestFile = "Builds/build_request.txt";
const string ResultFile = "Builds/last_build.txt";
static double nextCheck;
static ApkBuilder()
{
EditorApplication.update += Poll;
}
static void Poll()
{
if (EditorApplication.timeSinceStartup < nextCheck) return;
nextCheck = EditorApplication.timeSinceStartup + 2;
if (!File.Exists(RequestFile) || BuildPipeline.isBuildingPlayer || EditorApplication.isCompiling || EditorApplication.isPlaying) return;
File.Delete(RequestFile);
EditorApplication.delayCall += Build;
}
[MenuItem("Tools/Bogatyri/Собрать APK")]
public static void Build()
{
AndroidBuildFixes.EnsureAsciiGradleHome();
Directory.CreateDirectory(Folder);
string branch = Git("rev-parse --abbrev-ref HEAD").Replace('/', '-');
if (branch.Length == 0) branch = "local";
string file = $"{Folder}/Bogatyri_{branch}_v{PlayerSettings.bundleVersion}_{DateTime.Now:yyyyMMdd_HHmm}.apk";
var scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
File.WriteAllText(ResultFile, $"Сборка идёт: {DateTime.Now:dd.MM.yyyy HH:mm}\n{file}\n");
BuildReport report;
try
{
EditorUserBuildSettings.buildAppBundle = false; // обычный APK
report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
{
scenes = scenes,
locationPathName = file,
target = BuildTarget.Android,
options = BuildOptions.None
});
}
catch (Exception e)
{
File.WriteAllText(ResultFile, $"ОШИБКА {DateTime.Now:dd.MM.yyyy HH:mm}\n{e}\n");
throw;
}
var s = report.summary;
var text = new StringBuilder();
text.AppendLine($"{(s.result == BuildResult.Succeeded ? "ГОТОВО" : "ОШИБКА")} {DateTime.Now:dd.MM.yyyy HH:mm} результат: {s.result}");
text.AppendLine($"Файл: {Path.GetFullPath(file)}");
if (s.result == BuildResult.Succeeded && File.Exists(file)) text.AppendLine($"Размер: {new FileInfo(file).Length / 1048576.0:0.0} МБ");
text.AppendLine($"Версия: {PlayerSettings.bundleVersion}, код {PlayerSettings.Android.bundleVersionCode}, ветка {branch} {Git("rev-parse --short HEAD")}");
text.AppendLine($"Сцены: {string.Join(", ", scenes)}");
text.AppendLine($"Время: {s.totalTime}, ошибок: {s.totalErrors}, предупреждений: {s.totalWarnings}");
int shown = 0;
foreach (var step in report.steps)
foreach (var m in step.messages)
if (m.type == UnityEngine.LogType.Error || m.type == UnityEngine.LogType.Exception)
{
if (shown++ >= 15) break;
string msg = m.content.Length > 1500 ? m.content.Substring(0, 1500) + " ..." : m.content;
text.AppendLine("--- " + msg);
}
File.WriteAllText(ResultFile, text.ToString());
UnityEngine.Debug.Log("[СБОРКА] " + text.ToString().Split('\n')[0] + " -> " + file);
if (s.result == BuildResult.Succeeded) EditorUtility.RevealInFinder(file);
}
static string Git(string args)
{
try
{
var p = Process.Start(new ProcessStartInfo("git", args)
{
RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true,
WorkingDirectory = Directory.GetCurrentDirectory()
});
string o = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit(5000);
return o;
}
catch { return ""; }
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 904233d7647ed024d920bdbbd4058aba
+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Diagnostics;
using System.IO;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
// Перед каждым билдом записывает в Assets/Resources/BuildInfo.txt: ветку, коммит и дату сборки (05.11).
// Игра показывает это в углу экрана рядом с версией (App/GameVersionLabel). После билда файл удаляется,
// чтобы не попадать в git.
public class BuildInfoWriter : IPreprocessBuildWithReport, IPostprocessBuildWithReport
{
const string Path = "Assets/Resources/BuildInfo.txt";
public int callbackOrder => 0;
public void OnPreprocessBuild(BuildReport report)
{
string branch = Or(Git("rev-parse --abbrev-ref HEAD")), commit = Or(Git("rev-parse --short HEAD"));
bool dirty = Git("status --porcelain --untracked-files=no").Length > 0;
string info = $"{branch} {commit}{(dirty ? "+" : "")} • {DateTime.Now:dd.MM.yyyy HH:mm}";
Directory.CreateDirectory("Assets/Resources");
File.WriteAllText(Path, info);
AssetDatabase.ImportAsset(Path);
UnityEngine.Debug.Log("[ВЕРСИЯ] Сборка: v" + PlayerSettings.bundleVersion + " • " + info);
}
public void OnPostprocessBuild(BuildReport report)
{
AssetDatabase.DeleteAsset(Path);
}
static string Git(string args)
{
try
{
var p = Process.Start(new ProcessStartInfo("git", args)
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Directory.GetCurrentDirectory()
});
string output = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit(5000);
return output;
}
catch { return ""; }
}
static string Or(string s) => string.IsNullOrEmpty(s) ? "?" : s;
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1218c67fef1faaf448c6623df897ac12
+119
View File
@@ -0,0 +1,119 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
// Создаёт сцену 3D-поселения (прототип, этап I ТЗ «Система строительства поселения» от 04.11.2026):
// Tools -> Bogatyri -> 3D-город: создать сцену City3D.
//
// Что делает:
// 1. Добавляет в настройки URP второй рендерер -- обычный 3D (Assets/Settings/Renderer3D.asset).
// Старые 2D-сцены продолжают рисоваться 2D-рендерером: он остаётся рендерером по умолчанию.
// 2. Создаёт Assets/Scenes/City3D.unity: камера (3D-рендерер, перспектива), солнце с тенями,
// объект City3D с City3DController -- всё остальное (земля, деревья, здания) он строит сам при запуске.
// 3. Добавляет сцену в Build Settings и открывает её. Дальше -- просто Play.
public static class City3DSetup
{
const string ScenePath = "Assets/Scenes/City3D.unity";
const string RendererPath = "Assets/Settings/Renderer3D.asset";
[MenuItem("Tools/Bogatyri/3D-город: создать сцену City3D")]
static void Create()
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
int rendererIndex = EnsureRenderer3D();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var camGo = new GameObject("Main Camera") { tag = "MainCamera" };
var cam = camGo.AddComponent<Camera>();
cam.orthographic = false;
cam.fieldOfView = 40f;
cam.nearClipPlane = 0.3f;
cam.farClipPlane = 300f;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.55f, 0.72f, 0.9f);
camGo.AddComponent<AudioListener>();
var data = camGo.AddComponent<UniversalAdditionalCameraData>();
if (rendererIndex >= 0) data.SetRenderer(rendererIndex);
camGo.AddComponent<City3DCamera>();
var sunGo = new GameObject("Sun");
var sun = sunGo.AddComponent<Light>();
sun.type = LightType.Directional;
sun.intensity = 1.2f;
sun.shadows = LightShadows.Soft;
sunGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
RenderSettings.ambientMode = AmbientMode.Flat;
RenderSettings.ambientLight = new Color(0.55f, 0.58f, 0.62f);
new GameObject("City3D").AddComponent<City3DController>();
EditorSceneManager.SaveScene(scene, ScenePath);
AddToBuildSettings();
Debug.Log($"[3D-ГОРОД] Сцена создана: {ScenePath}. 3D-рендерер: индекс {rendererIndex}. Нажмите Play.");
}
// Второй рендерер URP -- обычный (3D). Добавляется в URP Asset из Graphics Settings и из КАЖДОГО уровня качества
// (в этом проекте URP задан только в Quality). Возвращает индекс рендерера в списке (или -1)
[MenuItem("Tools/Bogatyri/3D: добавить 3D-рендерер в URP")]
static void AddRendererMenu()
{
int index = EnsureRenderer3D();
Debug.Log(index >= 0 ? $"[3D] 3D-рендерер подключён (индекс {index}). Можно запускать карту мира и City3D."
: "[3D] URP Asset не найден ни в Graphics, ни в Quality Settings.");
}
static int EnsureRenderer3D()
{
var pipelines = new System.Collections.Generic.List<UniversalRenderPipelineAsset>();
void Add(RenderPipelineAsset a) { if (a is UniversalRenderPipelineAsset u && !pipelines.Contains(u)) pipelines.Add(u); }
Add(GraphicsSettings.defaultRenderPipeline);
for (int q = 0; q < QualitySettings.names.Length; q++) Add(QualitySettings.GetRenderPipelineAssetAt(q));
if (pipelines.Count == 0)
{
Debug.LogWarning("[3D-ГОРОД] Нет URP Asset ни в Graphics, ни в Quality Settings -- камера будет рисовать рендерером по умолчанию");
return -1;
}
var renderer = AssetDatabase.LoadAssetAtPath<UniversalRendererData>(RendererPath);
if (renderer == null)
{
renderer = ScriptableObject.CreateInstance<UniversalRendererData>();
AssetDatabase.CreateAsset(renderer, RendererPath);
AssetDatabase.SaveAssets();
}
int result = -1;
foreach (var pipeline in pipelines)
{
var so = new SerializedObject(pipeline);
var list = so.FindProperty("m_RendererDataList");
int found = -1;
for (int i = 0; i < list.arraySize; i++)
if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) found = i;
if (found < 0)
{
list.arraySize++;
list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = renderer;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(pipeline);
found = list.arraySize - 1;
}
if (result < 0) result = found;
}
AssetDatabase.SaveAssets();
return result;
}
static void AddToBuildSettings()
{
var scenes = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
foreach (var s in scenes) if (s.path == ScenePath) return;
scenes.Add(new EditorBuildSettingsScene(ScenePath, true));
EditorBuildSettings.scenes = scenes.ToArray();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 37d458150cc84d538a23b4c6612995c7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+6 -3
View File
@@ -3,11 +3,13 @@ using UnityEditor;
// Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings --
// иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции.
// Стартовая сцена (05.11): 3D-поселение City3D, если оно есть в проекте, иначе 2D-город MVP1.
[InitializeOnLoad]
public static class SceneListFixer
{
const string CityScene = "Assets/Scenes/MVP1.unity";
const string WorldScene = "Assets/Scenes/WorldMap.unity";
const string City3DScene = "Assets/Scenes/City3D.unity";
static SceneListFixer() { EditorApplication.delayCall += Ensure; }
@@ -15,15 +17,16 @@ public static class SceneListFixer
{
var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
bool changed = false;
foreach (var path in new[] { CityScene, WorldScene })
string start = AssetDatabase.LoadAssetAtPath<SceneAsset>(City3DScene) != null ? City3DScene : CityScene;
foreach (var path in new[] { start, CityScene, WorldScene })
{
if (AssetDatabase.LoadAssetAtPath<SceneAsset>(path) == null) continue;
if (scenes.Exists(s => s.path == path)) continue;
scenes.Add(new EditorBuildSettingsScene(path, true));
changed = true;
}
// город должен быть первым (с него стартует игра)
int city = scenes.FindIndex(s => s.path == CityScene);
// стартовый город должен быть первым (с него стартует игра)
int city = scenes.FindIndex(s => s.path == start);
if (city > 0) { var c = scenes[city]; scenes.RemoveAt(city); scenes.Insert(0, c); changed = true; }
if (changed)
{
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dd067b5f8139dbc46b51ef157b0f0613
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+52
View File
@@ -0,0 +1,52 @@
%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: 8
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
missingSharePermille: 0
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b666973e44a2442b82f2782adc99852f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+5
View File
@@ -18,6 +18,11 @@ MonoBehaviour:
startSoldiers: 30
foodPerCivilianPerHour: 0.1
foodPerSoldierPerHour: 0.2
foodPerTroopTypePerHour:
- 0.15
- 0.2
- 0.15
- 0.15
growthPerHour: 2.5
housingBase: 300
housingPerTownHallLevel: 50
+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: c25bbe993b314a15a6d1a8aaf0017d55, type: 3}
m_Name: HospitalConfig
m_EditorClassIdentifier:
bedsByLevel: 140000001e00000028000000320000003c000000
temporaryBeds: 5
requiredTownHallLevel: 1
buildWood: 250
buildStone: 150
healGameHours: 04000000040000000600000008000000
healFoodPerSoldier: 00000000000000000000000000000000
healGoldPerSoldier: 01000000010000000200000003000000
missingReturnPercent: 50
missingWoundedPercent: 30
missingWindowGameHours: 6
missingMaxGroups: 4
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 18e162eeff424f8bb3a71543eafba0c8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+137
View File
@@ -0,0 +1,137 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-7493335797097805923
MonoBehaviour:
m_ObjectHideFlags: 11
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: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Map3DLit
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 1
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap:
RenderType: Opaque
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _Metallic: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.12
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0ec4d2749e73404eba2facea709d9f24
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
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:
+799
View File
@@ -0,0 +1,799 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.55, g: 0.58, b: 0.62, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 3
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 0
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 2
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 1
m_PVRFilteringGaussRadiusAO: 1
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &110794169
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 110794174}
- component: {fileID: 110794173}
- component: {fileID: 110794172}
- component: {fileID: 110794171}
- component: {fileID: 110794170}
- component: {fileID: 110794175}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &110794170
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e8645f0028574904969b5802443f100f, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::City3DCamera
pitch: 52
yaw: 45
distance: 26
minDistance: 8
maxDistance: 55
target: {x: 0, y: 0, z: 0}
bounds:
serializedVersion: 2
x: 0
y: 0
width: 48
height: 48
--- !u!114 &110794171
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_VolumeFrameworkUpdateModeOption: 2
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_AllowHDROutput: 1
m_UseScreenCoordOverride: 0
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_TaaSettings:
m_Quality: 3
m_FrameInfluence: 0.1
m_JitterScale: 1
m_MipBias: 0
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
m_Version: 2
--- !u!81 &110794172
AudioListener:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
--- !u!20 &110794173
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 2
m_BackGroundColor: {r: 0.55, g: 0.72, b: 0.9, a: 1}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 300
field of view: 40
orthographic: 1
orthographic size: 5
m_Depth: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &110794174
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &110794175
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 772c25e1ad11ec84191ddd62a2559d77, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::CameraController
moveSpeed: 5
zoomSpeed: 2
minOrthoSize: 2
maxOrthoSize: 50
--- !u!1 &429953705
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 429953708}
- component: {fileID: 429953707}
- component: {fileID: 429953706}
m_Layer: 0
m_Name: EventSystem
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &429953706
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 429953705}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4f231c4fb786f3946a6b90b886c48677, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.StandaloneInputModule
m_SendPointerHoverToParent: 1
m_HorizontalAxis: Horizontal
m_VerticalAxis: Vertical
m_SubmitButton: Submit
m_CancelButton: Cancel
m_InputActionsPerSecond: 10
m_RepeatDelay: 0.5
m_ForceModuleActive: 0
--- !u!114 &429953707
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 429953705}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 76c392e42b5098c458856cdf6ecaaaa1, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.EventSystem
m_FirstSelected: {fileID: 0}
m_sendNavigationEvents: 1
m_DragThreshold: 10
--- !u!4 &429953708
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 429953705}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1001 &695231209
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
serializedVersion: 3
m_TransformParent: {fileID: 0}
m_Modifications:
- target: {fileID: 390569600188462384, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: -23.000854
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Pivot.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Pivot.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalPosition.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.w
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalEulerAnglesHint.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 4178416176766117917, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 4178416176766117917, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 5046265208400373706, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_sharedMaterial
value:
objectReference: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
- target: {fileID: 5449404431136268404, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Camera
value:
objectReference: {fileID: 110794173}
- target: {fileID: 5795784041573784673, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Name
value: HUD_Canvas
objectReference: {fileID: 0}
- target: {fileID: 6329222681380648929, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_sharedMaterial
value:
objectReference: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
- target: {fileID: 7064072091532955446, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: -6.4188232
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
m_AddedComponents: []
m_SourcePrefab: {fileID: 100100000, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
--- !u!1 &842753593
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 842753595}
- component: {fileID: 842753594}
m_Layer: 0
m_Name: RecourceManager
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &842753594
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 842753593}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5505825aa4d83af49a3a641e48af3ff7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::ResourceManager
startWood: 0
startStone: 0
startIron: 0
startFood: 0
startGold: 0
startGems: 0
startPower: 0
startPopulation: 0
--- !u!4 &842753595
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 842753593}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 746.5865, y: 295.47598, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1080091918
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1080091921}
- component: {fileID: 1080091920}
- component: {fileID: 1080091919}
m_Layer: 0
m_Name: SaveManager
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1080091919
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1080091918}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0f2bb66f2486f084b813736d3a8768d7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::GameBootstrap
--- !u!114 &1080091920
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1080091918}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 749721a5613594941adaed89b39ea5fe, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::SaveManager
autoSaveInterval: 30
--- !u!4 &1080091921
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1080091918}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -11.03001, y: 0.33877, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1225367365
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1225367367}
- component: {fileID: 1225367366}
m_Layer: 0
m_Name: City3D
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1225367366
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1225367365}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 44d822c903694362bef912c055adda5b, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::City3DController
--- !u!4 &1225367367
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1225367365}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1935466178
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1935466180}
- component: {fileID: 1935466179}
m_Layer: 0
m_Name: Sun
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!108 &1935466179
Light:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935466178}
m_Enabled: 1
serializedVersion: 13
m_Type: 1
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Intensity: 1.2
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize2D: {x: 0.5, y: 0.5}
m_Shadows:
m_Type: 2
m_Resolution: -1
m_CustomResolution: -1
m_Strength: 1
m_Bias: 0.05
m_NormalBias: 0.4
m_NearPlane: 0.2
m_CullingMatrixOverride:
e00: 1
e01: 0
e02: 0
e03: 0
e10: 0
e11: 1
e12: 0
e13: 0
e20: 0
e21: 0
e22: 1
e23: 0
e30: 0
e31: 0
e32: 0
e33: 1
m_UseCullingMatrixOverride: 0
m_Cookie: {fileID: 0}
m_DrawHalo: 0
m_Flare: {fileID: 0}
m_RenderMode: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingLayerMask: 1
m_Lightmapping: 4
m_LightShadowCasterMode: 0
m_AreaSize: {x: 1, y: 1}
m_BounceIntensity: 1
m_ColorTemperature: 6570
m_UseColorTemperature: 0
m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
m_UseBoundingSphereOverride: 0
m_UseViewFrustumForShadowCasterCull: 1
m_ForceVisible: 0
m_ShapeRadius: 0.025
m_ShadowAngle: 0
m_LightUnit: 1
m_LuxAtDistance: 1
m_EnableSpotReflector: 1
--- !u!4 &1935466180
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935466178}
serializedVersion: 2
m_LocalRotation: {x: 0.40821794, y: -0.23456973, z: 0.10938166, w: 0.8754261}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 110794174}
- {fileID: 1935466180}
- {fileID: 1225367367}
- {fileID: 429953708}
- {fileID: 1080091921}
- {fileID: 842753595}
- {fileID: 695231209}
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c1c34719f0605c247a3575d077f3d656
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+90
View File
@@ -0,0 +1,90 @@
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("Пропавшие без вести (решения 01.11, п. 5): доля выбывших, десятые доли процента. НЕ УТВЕРЖДЕНА -- 0, механика выключена. Для проверки -- например 150 (15%)")]
public int missingSharePermille = 0;
[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,
MissingSharePermille = Mathf.Clamp(missingSharePermille, 0, 1000),
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:
+691 -72
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,738 @@ 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);
var card = UIKit.Modal(TitleFor(focus), out root);
if (root != null) UnityEngine.Object.Destroy(root);
live.Clear();
var modal = UIKit.Modal(TitleFor(focus), out root);
root.AddComponent<BuildingWindowTicker>();
var card = ScrollBody(modal); // длинное окно прокручивается, "Закрыть" всегда видна внизу (05.11)
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;
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
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(modal, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
}
// Окно не выше экрана: заголовок сверху, содержимое прокручивается, кнопка "Закрыть" -- внизу, вне прокрутки.
// Закрыть можно и тапом по затемнению вокруг окна, и кнопкой "Назад" (Escape).
static RectTransform ScrollBody(RectTransform modal)
{
var fit = modal.GetComponent<ContentSizeFitter>();
if (fit != null) UnityEngine.Object.DestroyImmediate(fit);
modal.anchorMin = new Vector2(0.06f, 0.05f);
modal.anchorMax = new Vector2(0.94f, 0.95f);
modal.offsetMin = Vector2.zero;
modal.offsetMax = Vector2.zero;
var dim = root.GetComponent<Image>();
if (dim != null)
{
var outside = root.AddComponent<Button>();
outside.transition = Selectable.Transition.None;
outside.onClick.AddListener(Close);
}
var holder = UIKit.Make("Body", modal);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
ScrollRect scroll;
return UIKit.ScrollList(holder, out scroll, 14);
}
// Стройка закончилась (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());
LiveCost(card, cost);
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);
LiveAfford(b, canPay, () => townHall != null && townHall.CanUpgrade());
}
// Каких зданий на следующем уровне Ратуши можно построить больше: "Лес 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);
LiveCost(card, cost);
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);
LiveAfford(b, canPay, () => GameServices.TryGet(out IEconomy now) && now.CanAfford(cost));
}
// Кнопка «Улучшить» следит за ресурсами, пока окно открыто: ресурсов стало хватать -- зелёная и нажимается,
// перестало -- серая (05.11: раньше нужно было закрыть и открыть окно)
static void LiveAfford(Button b, bool canPay, Func<bool> can)
{
b.interactable = canPay;
bool shown = canPay;
live.Add(() =>
{
if (b == null) return;
bool c = can();
if (c == shown) return;
shown = c;
b.interactable = c;
var img = b.GetComponent<Image>();
if (img != null) img.color = c ? UIKit.Theme.Green : UIKit.Theme.Gray;
});
}
// ---------------- Стройка по времени (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);
}
// Цена: чего хватает -- зелёным, чего нет -- красным, со сколько есть
// Строка «Цена: …» обновляется, пока окно открыто: хватает ресурса -- зелёная цифра, нет -- красная и «есть N» (05.11)
static void LiveCost(Transform card, ResourceAmount[] cost)
{
var text = UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
string shown = text.text;
live.Add(() =>
{
if (text == null) return;
string now = "Цена: " + CostText(cost);
if (now != shown) { shown = now; text.text = now; }
});
}
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 (Input.GetKeyDown(KeyCode.Escape)) { BuildingWindow.Close(); return; } // "Назад" на телефоне
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
BuildingWindow.Tick();
}
}
+14 -15
View File
@@ -5,7 +5,8 @@ using UnityEngine;
// Слой приложения: сообщает модулям, какие здания стоят в городе.
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
// - Дома (здание "Население") -> модуль Населения (предел жилья);
// - Склады и Амбары -> лимиты склада в Экономике.
// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection).
// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают.
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
//
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
@@ -16,6 +17,10 @@ public class CitySync : MonoBehaviour
public static CitySync Instance { get; private set; }
// Город открыт не в 2D-сцене, а в 3D (City3D): здания с PlacedBuildingId там тоже есть (05.11)
public static bool ExternalCity;
static bool CityOpen => BuildManager.Instance != null || ExternalCity;
float next;
string pending; // замер, который ждёт подтверждения
string applied; // последний применённый замер
@@ -38,7 +43,7 @@ public class CitySync : MonoBehaviour
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + Interval;
if (BuildManager.Instance == null) return; // город не открыт -- модули помнят последнее
if (!CityOpen) return; // город не открыт -- модули помнят последнее
var city = Scan();
if (city.Signature == applied) { pending = null; return; }
@@ -49,7 +54,7 @@ public class CitySync : MonoBehaviour
// Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места)
public void SyncNow()
{
if (BuildManager.Instance == null) return;
if (!CityOpen) return;
Apply(Scan());
}
@@ -67,6 +72,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 +118,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:
+149 -6
View File
@@ -9,14 +9,15 @@ using UnityEngine.UI;
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
//
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население). Походы и обучение
// войск пока идут по реальным часам.
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение
// войск пока идёт по реальным часам.
public class DebugPanel : MonoBehaviour
{
static DebugPanel instance;
GameObject panel;
TextMeshProUGUI info;
UnityEngine.UI.Button gridButton;
float nextRefresh;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
@@ -38,15 +39,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);
@@ -64,8 +73,9 @@ public class DebugPanel : MonoBehaviour
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
var row2 = UIKit.Row(card, 70, 10);
Btn(row2, "+100 всех ресурсов", UIKit.Theme.Green, AddAllResources);
Btn(row2, "+1000 всех ресурсов", UIKit.Theme.Green, AddAllResources);
Btn(row2, "+1000 еды", UIKit.Theme.Green, () => AddResource(ResourceType.Food, 1000));
Btn(row2, "Кристаллы = макс", UIKit.Theme.Blue, FillGems);
var row3 = UIKit.Row(card, 70, 10);
Btn(row3, "Еда = 0", UIKit.Theme.Red, ZeroFood);
@@ -73,10 +83,132 @@ public class DebugPanel : MonoBehaviour
var row4 = UIKit.Row(card, 70, 10);
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
gridButton = UIKit.Button(row4, GridLabel, UIKit.Theme.Blue, ToggleGrid, -1, 70, 24);
UIKit.Size(gridButton, -1, 70, 1);
Btn(row4, "Камера", UIKit.Theme.Blue, ToggleCameraCard);
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => { panel.SetActive(false); if (cameraCard != null) cameraCard.SetActive(false); });
panel.SetActive(false);
}
// ---------------- Камера 3D-поселения (решение 05.11: перспектива, числа подбираем в Unity и на телефоне) ----------------
GameObject cameraCard;
TextMeshProUGUI cameraInfo;
// Ползунок: подпись, значение, диапазон. get/set -- поле камеры
struct CamSlider { public string Label, Format; public float Min, Max; public System.Func<City3DCamera, float> Get; public System.Action<City3DCamera, float> Set; public string Pref; }
static readonly CamSlider[] CamSliders =
{
new CamSlider { Label = "Наклон", Format = "0°", Min = 25, Max = 85, Pref = City3DCamera.PrefPitch,
Get = c => c.pitch, Set = (c, v) => c.pitch = v },
new CamSlider { Label = "Перспектива", Format = "0°", Min = 15, Max = 75, Pref = City3DCamera.PrefFov,
Get = c => c.fieldOfView, Set = (c, v) => c.SetFieldOfView(v) },
new CamSlider { Label = "Ближе всего", Format = "0", Min = 3, Max = 30, Pref = City3DCamera.PrefMin,
Get = c => c.minDistance, Set = (c, v) => { c.minDistance = Mathf.Min(v, c.maxDistance - 1); c.distance = Mathf.Max(c.distance, c.minDistance); } },
new CamSlider { Label = "Дальше всего", Format = "0", Min = 20, Max = 120, Pref = City3DCamera.PrefMax,
Get = c => c.maxDistance, Set = (c, v) => { c.maxDistance = Mathf.Max(v, c.minDistance + 1); c.distance = Mathf.Min(c.distance, c.maxDistance); } },
new CamSlider { Label = "Сейчас", Format = "0", Min = 3, Max = 120, Pref = null,
Get = c => c.distance, Set = (c, v) => c.distance = Mathf.Clamp(v, c.minDistance, c.maxDistance) },
};
static City3DCamera Cam3D => Object.FindAnyObjectByType<City3DCamera>();
void ToggleCameraCard()
{
if (cameraCard != null) { bool show = !cameraCard.activeSelf; if (show) BuildCameraCard(); else cameraCard.SetActive(false); return; }
BuildCameraCard();
}
void BuildCameraCard()
{
if (cameraCard != null) Destroy(cameraCard);
var cam = Cam3D;
var safe = panel.transform.parent;
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 + 640, 0);
card.sizeDelta = new Vector2(640, 0);
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
cameraCard = card.gameObject;
var title = UIKit.Text(card, "КАМЕРА 3D-ПОСЕЛЕНИЯ (ТЕСТ)", 28, UIKit.Theme.Gold, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
if (cam == null)
{
UIKit.Text(card, "Откройте 3D-город (сцена City3D)", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
else
{
foreach (var def in CamSliders)
{
var d = def;
var row = UIKit.Row(card, 60, 12);
var label = UIKit.Text(row, d.Label, 24);
UIKit.Size(label, 170);
var value = UIKit.Text(row, d.Get(cam).ToString(d.Format), 24, UIKit.Theme.TextDim, TextAlignmentOptions.Right);
UIKit.Slider(row, Mathf.InverseLerp(d.Min, d.Max, d.Get(cam)), t =>
{
var c = Cam3D;
if (c == null) return;
d.Set(c, Mathf.Lerp(d.Min, d.Max, t));
value.text = d.Get(c).ToString(d.Format);
if (d.Pref != null) PlayerPrefs.SetFloat(d.Pref, d.Get(c)); // запомнить на этом устройстве
c.Apply();
UpdateCameraInfo();
});
value.transform.SetAsLastSibling();
UIKit.Size(value, 80);
}
}
cameraInfo = UIKit.Text(card, "", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UpdateCameraInfo();
var row2 = UIKit.Row(card, 64, 10);
Btn(row2, "Сбросить", UIKit.Theme.Gray, ResetCamera);
Btn(row2, "Закрыть", UIKit.Theme.Gray, () => cameraCard.SetActive(false));
}
// Строка, которую удобно переписать в отчёт / в сцену
void UpdateCameraInfo()
{
var c = Cam3D;
if (cameraInfo == null) return;
cameraInfo.text = c == null ? "" :
$"pitch {c.pitch:0} • fieldOfView {c.fieldOfView:0} • minDistance {c.minDistance:0} • maxDistance {c.maxDistance:0} • distance {c.distance:0}\n" +
"Подобранное запоминается на этом устройстве. В сцену (для всех) -- вписать числа в Main Camera → City3DCamera";
}
void ResetCamera()
{
foreach (var d in CamSliders) if (d.Pref != null) PlayerPrefs.DeleteKey(d.Pref);
var c = Cam3D;
if (c != null)
{
c.pitch = c.DefaultPitch;
c.SetFieldOfView(c.DefaultFov);
c.minDistance = c.DefaultMin;
c.maxDistance = c.DefaultMax;
c.distance = Mathf.Clamp(c.distance, c.minDistance, c.maxDistance);
c.Apply();
}
BuildCameraCard();
}
// Отладочная сетка стройки (3D-поселение и 2D-город): видна всегда, ярче, каждая 5-я линия жёлтая (05.11, просьба Миши)
void ToggleGrid()
{
bool on = !(City3DController.DebugGrid || BuildGrid.DebugVisible);
City3DController.SetDebugGrid(on);
bool building = BuildModeController.Instance != null && BuildModeController.Instance.IsActive;
BuildGrid.SetDebugVisible(on, building);
if (gridButton != null) UIKit.SetButton(gridButton, GridLabel, true);
}
static string GridLabel => City3DController.DebugGrid || BuildGrid.DebugVisible ? "Сетка: вкл" : "Сетка: выкл";
static void Btn(Transform row, string label, Color color, System.Action onClick)
{
var b = UIKit.Button(row, label, color, onClick, -1, 70, 24);
@@ -133,7 +265,18 @@ public class DebugPanel : MonoBehaviour
void AddAllResources()
{
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
foreach (var t in types) AddResource(t, 100);
foreach (var t in types) AddResource(t, 1000); // лишнее обрежет лимит склада
}
// Кристаллы до предела склада (лимит 50 -- решение 19.10, предел не меняется)
void FillGems()
{
var rm = ResourceManager.Instance;
if (rm == null) { UIKit.Toast("Нет склада (ResourceManager)"); return; }
float cap = rm.GetCapacity(ResourceType.Gems);
if (float.IsInfinity(cap)) cap = EconomyConfig.Load().storage.crystalCap;
rm.SetResource(ResourceType.Gems, cap);
Refresh();
}
void AddResource(ResourceType type, float amount)
+123 -2
View File
@@ -24,8 +24,11 @@ public class EconomyConfig : ScriptableObject
[Tooltip("Еды в час на одного жителя")]
public float foodPerCivilianPerHour = 0.1f;
[Tooltip("Еды в час на одного воина")]
[Tooltip("Еды в час на одного воина (и новобранца в Казармах), если норма по типам не задана")]
public float foodPerSoldierPerHour = 0.2f;
[Tooltip("Еды в час на воина по типам: пехота, лучники, кавалерия, осадные. Решения 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15. " +
"Раненые едят так же, даже ожидая койку. Пусто -- все по foodPerSoldierPerHour")]
public float[] foodPerTroopTypePerHour = { 0.15f, 0.2f, 0.15f, 0.15f };
[Tooltip("Прирост людей в час, если есть еда (документ: 2–3)")]
public float growthPerHour = 2.5f;
@@ -39,6 +42,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 +66,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 +112,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 +201,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 +258,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)
{
+124 -5
View File
@@ -16,15 +16,58 @@ 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(), HospitalConfig.Load().ToMissingRules()); // госпиталь, пропавшие
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));
// Пропавшие без вести вернулись / списаны (решения 01.11, п. 5)
EventBus.Subscribe<MissingReturnedEvent>(e =>
{
if (e.Healthy + e.Wounded > 0)
UIKit.Toast($"Вернулись пропавшие без вести: {e.Healthy + e.Wounded}" + (e.Wounded > 0 ? $" (ранены {e.Wounded} -- в госпиталь)" : ""));
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
});
EventBus.Subscribe<MissingWrittenOffEvent>(e =>
{
if (e.Count > 0) UIKit.Toast($"Не вернулись и списаны пропавшие без вести: {e.Count}");
if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged();
});
// Раненые вылечены -- вернулись в город
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 +90,23 @@ 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;
// 3D-поселение (сцена City3D, 05.11): свой город, своя секция сохранения "settlement3d"
SaveRegistry.Register(City3DSave.Instance);
// Защита ресурсов от грабежа (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 +126,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 +142,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 +212,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)
@@ -142,11 +254,17 @@ public static class GameModules
StartSoldiers = p.startSoldiers,
FoodPerCivilianPerHour = p.foodPerCivilianPerHour,
FoodPerSoldierPerHour = p.foodPerSoldierPerHour,
FoodPerTroopTypePerHour = p.foodPerTroopTypePerHour != null && p.foodPerTroopTypePerHour.Length > 0
? System.Array.ConvertAll(p.foodPerTroopTypePerHour, f => (double)f) : null,
GrowthPerHour = p.growthPerHour,
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 +277,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
};
}
+50
View File
@@ -0,0 +1,50 @@
using UnityEngine;
// Версия игры на экране (05.11, требование Миши: в каждом билде видно, что это за версия).
// В углу экрана: версия из Player Settings (Application.version) и сборка -- ветка, коммит, дата.
// Сборку пишет Editor/BuildInfoWriter перед билдом в Resources/BuildInfo.txt; в редакторе -- "редактор".
// Создаётся сам при запуске, живёт между сценами.
public class GameVersionLabel : MonoBehaviour
{
static GameVersionLabel instance;
string text;
GUIStyle style, shadow;
public static string BuildInfo
{
get
{
var asset = Resources.Load<TextAsset>("BuildInfo");
return asset != null ? asset.text.Trim() : "редактор";
}
}
public static string FullVersion => $"v{Application.version} • {BuildInfo}";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (instance != null) return;
var go = new GameObject("GameVersionLabel");
DontDestroyOnLoad(go);
instance = go.AddComponent<GameVersionLabel>();
instance.text = FullVersion;
Debug.Log("[ВЕРСИЯ] " + instance.text);
}
void OnGUI()
{
if (style == null)
{
int size = Mathf.Max(12, Screen.height / 60);
style = new GUIStyle(GUI.skin.label) { fontSize = size, alignment = TextAnchor.LowerLeft };
style.normal.textColor = new Color(1f, 1f, 1f, 0.75f);
shadow = new GUIStyle(style);
shadow.normal.textColor = new Color(0f, 0f, 0f, 0.6f);
}
GUI.depth = -1000; // поверх всего
var r = new Rect(8, Screen.height - style.fontSize * 2 - 4, Screen.width * 0.6f, style.fontSize * 2);
GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), text, shadow);
GUI.Label(r, text, style);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4dae86c365fe55948b878547633d712a
+89
View File
@@ -0,0 +1,89 @@
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 };
[Header("Пропавшие без вести (решения 01.11, п. 5). Долю пропавших в бою задаёт BattleConfig.missingSharePermille")]
[Tooltip("Сколько пропавших вернётся, %. Утверждён диапазон 10-90%, формула НЕ утверждена -- ТЕСТ")]
public int missingReturnPercent = 50;
[Tooltip("Из вернувшихся ранены, %. Утверждено: 30 (70% здоровыми)")]
public int missingWoundedPercent = 30;
[Tooltip("Окно возвращения после обратного пути, ИГРОВЫХ часов. Утверждено: до 6")]
public int missingWindowGameHours = 6;
[Tooltip("Возвращаются группами: от 2 до стольких. Утверждено: 2-4")]
public int missingMaxGroups = 4;
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 MissingRules ToMissingRules()
{
return new MissingRules
{
ReturnPercent = Mathf.Clamp(missingReturnPercent, 0, 100),
WoundedPercent = Mathf.Clamp(missingWoundedPercent, 0, 100),
WindowGameHours = Mathf.Max(0, missingWindowGameHours),
MaxGroups = Mathf.Max(2, missingMaxGroups)
};
}
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;
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: efa023adb1ce408c971a4f76ced19585
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,38 @@
using TMPro;
using UnityEngine;
// 3D-здание игры: его тип в 3D-сцене и подпись над ним -- имя, уровень, идёт ли стройка (05.11, Валдемар).
// Вешает City3DGameplay.Attach. Подпись обновляется дважды в секунду.
public class City3DBuildingTag : MonoBehaviour
{
public string type3d;
TextMeshPro label;
float next;
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
if (label == null)
{
var t = transform.Find("Label");
if (t != null) label = t.GetComponent<TextMeshPro>();
if (label == null) return;
}
string name = type3d;
int level = City3DGameplay.LevelOf(gameObject);
var timer = BuildTimer.Of(gameObject);
if (timer != null && BuildTimer.All.Contains(timer))
{
string left = GameData.FormatTime(timer.SecondsLeft);
label.text = timer.IsConstruction ? $"{name}\n<size=70%>стройка {left}</size>" : $"{name} {level} ур.\n<size=70%>→ {timer.targetLevel} ур. {left}</size>";
label.color = new Color(1f, 0.85f, 0.3f);
}
else
{
label.text = $"{name} {level} ур.";
label.color = Color.white;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0bcf6ea4f7ae10b4686b3fd647b0c02f
+170
View File
@@ -0,0 +1,170 @@
using UnityEngine;
// КАМЕРА 3D-ПОСЕЛЕНИЯ (2.5D): наклонный вид под фиксированным углом, без вращения.
// Перемещение -- перетаскиванием (палец / мышь), приближение -- двумя пальцами или колесом.
// Не уходит за пределы полезной карты. FocusOn -- плавно показать область (открытие новой зоны).
public class City3DCamera : MonoBehaviour
{
public float pitch = 52f; // наклон, градусы от горизонта
public float yaw = 45f; // поворот вида вокруг вертикали
public float distance = 26f;
public float minDistance = 8f;
public float maxDistance = 55f;
public Vector3 target; // точка на земле, на которую смотрит камера
public Rect bounds = new Rect(0, 0, 48, 48); // где может быть target (x, z)
Camera cam;
Vector3 focusTarget;
float focusDistance;
bool focusing;
Vector3 lastPointer;
bool panning;
// Перемещение камеры пальцем можно выключить на время перетаскивания здания
public bool AllowPan { get; set; } = true;
// Камера перспективная (решение 05.11): поселение объёмнее, есть глубина. Угол, высота и поле зрения -- ТЕСТ,
// подбираются в Unity и на телефоне. Ортографическую можно вернуть галочкой (тогда зум -- размер кадра, см. Apply)
public bool perspective = true;
public float fieldOfView = 40f;
// Подбор камеры в панели DEV (окно «Камера»): наклон, поле зрения, ближе / дальше всего. Запоминается на этом
// устройстве -- только в редакторе и отладочной сборке; в обычной сборке -- числа из сцены (05.11)
public const string PrefPitch = "dev_cam3d_pitch", PrefFov = "dev_cam3d_fov", PrefMin = "dev_cam3d_min", PrefMax = "dev_cam3d_max";
// Числа по умолчанию (из сцены) -- для кнопки «Сбросить» в DEV
public float DefaultPitch { get; private set; }
public float DefaultFov { get; private set; }
public float DefaultMin { get; private set; }
public float DefaultMax { get; private set; }
public void SetFieldOfView(float fov)
{
fieldOfView = fov;
if (cam != null && !cam.orthographic) cam.fieldOfView = fov;
}
void LoadDevTuning()
{
DefaultPitch = pitch; DefaultFov = fieldOfView; DefaultMin = minDistance; DefaultMax = maxDistance;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
pitch = PlayerPrefs.GetFloat(PrefPitch, pitch);
fieldOfView = PlayerPrefs.GetFloat(PrefFov, fieldOfView);
minDistance = PlayerPrefs.GetFloat(PrefMin, minDistance);
maxDistance = Mathf.Max(minDistance + 1f, PlayerPrefs.GetFloat(PrefMax, maxDistance));
distance = Mathf.Clamp(distance, minDistance, maxDistance);
#endif
}
void Awake()
{
cam = GetComponent<Camera>();
LoadDevTuning();
if (cam != null && perspective)
{
cam.orthographic = false;
cam.fieldOfView = fieldOfView;
}
}
public void FocusOn(Rect area)
{
focusTarget = new Vector3(area.center.x, 0, area.center.y);
// отойти так, чтобы область поместилась по высоте экрана (с запасом)
float size = Mathf.Max(area.width, area.height);
focusDistance = Mathf.Clamp(size * 1.15f, minDistance, maxDistance);
focusing = true;
}
// Начать / продолжить / закончить перемещение камеры. Зовёт контроллер ввода (он решает, что под пальцем)
public void BeginPan(Vector3 screen) { panning = AllowPan; lastPointer = screen; focusing = false; }
public void EndPan() { panning = false; }
// Палец ещё не сдвинулся настолько, чтобы считать это перетаскиванием: камера стоит, но точка запоминается --
// когда перетаскивание начнётся, карта не прыгнет (как на карте мира, 05.11)
public void Track(Vector3 screen) { lastPointer = screen; }
public void Pan(Vector3 screen)
{
if (!panning || cam == null) return;
Vector3 a = GroundPoint(lastPointer), b = GroundPoint(screen);
target += a - b;
lastPointer = screen;
Clamp();
Apply(); // сразу: иначе следующий шаг считался бы от старого положения камеры -- камера дёргалась (05.11)
}
public void Zoom(float delta)
{
focusing = false;
distance = Mathf.Clamp(distance * (1f - delta), minDistance, maxDistance);
}
void Update()
{
// Колесо мыши (редактор, ПК)
float wheel = Input.mouseScrollDelta.y;
if (City3DInput.OverHud(Input.mousePosition)) wheel = 0f; // колесо над окнами HUD -- не приближать камеру (05.11)
if (Mathf.Abs(wheel) > 0.01f) Zoom(wheel * 0.1f);
// Два пальца -- приближение, как на карте мира: расстояние камеры меняется во столько раз,
// во сколько изменилось расстояние между пальцами за кадр (не зависит от того, увидели ли касание) (05.11)
if (Input.touchCount >= 2)
{
Touch a = Input.GetTouch(0), b = Input.GetTouch(1);
float previous = ((a.position - a.deltaPosition) - (b.position - b.deltaPosition)).magnitude;
float current = (a.position - b.position).magnitude;
if (previous > 0.01f && current > 0.01f)
{
focusing = false;
distance = Mathf.Clamp(distance * previous / current, minDistance, maxDistance);
}
}
if (focusing)
{
target = Vector3.Lerp(target, focusTarget, Time.unscaledDeltaTime * 2.5f);
distance = Mathf.Lerp(distance, focusDistance, Time.unscaledDeltaTime * 2.5f);
if (Vector3.Distance(target, focusTarget) < 0.05f && Mathf.Abs(distance - focusDistance) < 0.05f) focusing = false;
}
Apply();
}
void Clamp()
{
target.x = Mathf.Clamp(target.x, bounds.xMin, bounds.xMax);
target.z = Mathf.Clamp(target.z, bounds.yMin, bounds.yMax);
target.y = 0;
}
// Дымка начинается за городом, а не на фиксированном расстоянии от камеры: иначе при отдалении весь город
// уходил в серую дымку -- казалось, что камера не отдаляется, а сцена сереет (05.11)
public float fogStartBehind = 4f, fogEndBehind = 59f;
public void Apply()
{
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
transform.rotation = rot;
transform.position = target - rot * Vector3.forward * distance;
// Камера сцены может быть ортографической (вид 2.5D без перспективы): у неё расстояние не меняет размер города
// на экране -- «+ / −» и щипок ничего не делали (05.11). Тогда приближение -- через размер кадра: столько же
// земли, сколько видно перспективной камерой с этого расстояния
if (cam == null) cam = GetComponent<Camera>();
if (cam != null && cam.orthographic)
cam.orthographicSize = distance * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad);
if (RenderSettings.fog && RenderSettings.fogMode == FogMode.Linear)
{
RenderSettings.fogStartDistance = distance + fogStartBehind;
RenderSettings.fogEndDistance = distance + fogEndBehind;
}
}
// Точка на плоскости земли (y = 0) под экранной точкой
Vector3 GroundPoint(Vector3 screen)
{
Ray ray = cam.ScreenPointToRay(screen);
var plane = new Plane(Vector3.up, Vector3.zero);
return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e8645f0028574904969b5802443f100f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 44d822c903694362bef912c055adda5b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+234
View File
@@ -0,0 +1,234 @@
using System.Collections.Generic;
using UnityEngine;
// 3D-ПОСЕЛЕНИЕ: здания 3D-сцены становятся зданиями игры (05.11, Валдемар).
// На 3D-здание вешаются те же компоненты, что на префабы 2D-города: рабочее место или Дом (ResourceCollector),
// Склад / Амбар (StorageBuilding), Казармы, Госпиталь, Ратуша (TownHall) и PlacedBuildingId с именем здания.
// Поэтому дальше всё работает так же, как в 2D: CitySync даёт места, жильё и лимиты; окно здания (BuildingWindow) --
// рабочие, добыча, улучшение по таймеру; стройка идёт через BuildTimer (цена, строители, лимиты, ускорение, офлайн).
// Место на карте проверяет SettlementMap Саши, цену, время и лимиты -- этот код и EconomyConfig.
public static class City3DGameplay
{
// Имя типа 3D-сцены -> имя здания игры (как в 2D: по нему лимиты, окна, сохранение)
public static string GameName(string type3d)
{
switch (type3d)
{
case "Дом": return "Население";
case "Ферма": return "Еда";
case "Лесопилка": return "Лес";
case "Каменоломня": return "Камень";
case "Шахта": return "Металл";
case "Склад": return StorageBootstrap.WarehouseName;
case "Амбар": return StorageBootstrap.GranaryName;
case "Казармы": return BarracksBootstrap.BarracksName;
case "Госпиталь": return HospitalBootstrap.HospitalName;
case "Золотой рудник": return "Золото";
case "Кристалл": return "Кристалл";
case "Терем": return HeroCastleBootstrap.CastleName;
default: return type3d; // "Ратуша"
}
}
// Обратно: имя здания игры (как в 2D-сохранении) -> тип 3D-сцены. null -- такого здания в 3D нет
public static string Type3DFor(string gameName)
{
foreach (var t in AllTypes3D)
if (GameName(t) == gameName) return t;
return null;
}
static readonly string[] AllTypes3D =
{
"Ратуша", "Дом", "Ферма", "Лесопилка", "Каменоломня", "Шахта", "Склад", "Амбар", "Казармы", "Госпиталь",
"Золотой рудник", "Кристалл", "Терем",
};
public static bool IsTownHall(string type3d) => type3d == "Ратуша";
// Цена постройки -- та же, что у зданий 2D-города (Data_*.asset, Склады -- EconomyConfig, Госпиталь -- HospitalConfig)
public static ResourceAmount[] BuildCost(string type3d)
{
var e = EconomyConfig.Load().storage;
switch (type3d)
{
case "Дом": return Cost(ResourceType.Wood, 100, ResourceType.Food, 200, ResourceType.Stone, 150);
case "Ферма": return Cost(ResourceType.Wood, 100);
case "Лесопилка": return Cost();
case "Каменоломня": return Cost(ResourceType.Wood, 50);
case "Шахта": return Cost(ResourceType.Wood, 600, ResourceType.Stone, 550);
case "Склад": return Cost(ResourceType.Wood, e.warehouseWood, ResourceType.Stone, e.warehouseStone);
case "Амбар": return Cost(ResourceType.Wood, e.granaryWood, ResourceType.Stone, e.granaryStone);
case "Казармы": return Cost(ResourceType.Wood, 300, ResourceType.Stone, 200);
case "Госпиталь":
var h = HospitalConfig.Load();
return Cost(ResourceType.Wood, h.buildWood, ResourceType.Stone, h.buildStone);
// Перенесены из 2D-города (решение 05.11): цены -- из Data_GoldMine, Data_Kristall и HeroCastleBootstrap
case "Золотой рудник": return Cost(ResourceType.Stone, 50);
case "Кристалл": return Cost(ResourceType.Wood, 2000, ResourceType.Stone, 3200, ResourceType.Gold, 12000, ResourceType.Iron, 1600);
case "Терем": return Cost(ResourceType.Wood, HeroCastleBootstrap.CostWood, ResourceType.Stone, HeroCastleBootstrap.CostStone);
default: return Cost();
}
}
// С какого уровня Ратуши можно строить (как в 2D)
public static int RequiredTownHall(string type3d)
{
switch (type3d)
{
case "Шахта": return 3;
case "Золотой рудник": return 2;
case "Кристалл": return 6;
case "Госпиталь": return Mathf.Max(1, HospitalConfig.Load().requiredTownHallLevel);
default: return 1;
}
}
static ResourceAmount[] Cost(params object[] pairs)
{
var list = new List<ResourceAmount>();
for (int i = 0; i + 1 < pairs.Length; i += 2)
list.Add(new ResourceAmount((ResourceType)pairs[i], System.Convert.ToSingle(pairs[i + 1])));
return list.ToArray();
}
public static int TownHallLevel => GameServices.TryGet(out ISettlement s) ? s.TownHallLevel : 1;
// Можно ли строить: уровень Ратуши, лимит зданий, строители, ресурсы. null -- можно, иначе текст для игрока
public static string CanBuild(string type3d)
{
int th = TownHallLevel;
if (th < RequiredTownHall(type3d)) return $"Откроется на Ратуше {RequiredTownHall(type3d)} ур.";
string name = GameName(type3d);
int max = EconomyConfig.Load().BuildingLimitFor(name, th), have = CountBuilt(name);
if (max >= 0 && have >= max) return $"Лимит: {have} / {max} на Ратуше {th} ур.";
if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) return "Все строители заняты";
var cost = BuildCost(type3d);
if (cost.Length > 0 && (!GameServices.TryGet(out IEconomy economy) || !economy.CanAfford(cost))) return "Не хватает ресурсов: " + CostText(cost);
return null;
}
public static string CostText(ResourceAmount[] cost)
{
if (cost.Length == 0) return "бесплатно";
var parts = new List<string>();
foreach (var c in cost) parts.Add($"{c.Amount:0} {GameData.ResName(c.Type).ToLower()}");
return string.Join(", ", parts.ToArray());
}
public static int CountBuilt(string gameName)
{
int n = 0;
foreach (var id in Object.FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
if (id.buildingName == gameName) n++;
return n;
}
// Новое здание поставлено на карту: списать цену и начать стройку по времени (BuildTimer)
public static void StartConstruction(GameObject view)
{
var tag = view.GetComponent<City3DBuildingTag>();
if (tag == null) return;
var cost = BuildCost(tag.type3d);
if (cost.Length > 0 && GameServices.TryGet(out IEconomy economy)) economy.TrySpend(cost);
long ms = BuildTimer.DurationMs(false, 1);
if (ms > 0)
{
long now = GameClock.UnixNowMs;
BuildTimer.Begin(view, BuildTimer.Kind.Build, 1, now, now + ms);
}
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
}
// Повесить на 3D-здание компоненты здания игры (как у префабов 2D). Без цены и таймера
public static void Attach(GameObject view, string type3d, int level)
{
var tag = view.GetComponent<City3DBuildingTag>() ?? view.AddComponent<City3DBuildingTag>();
tag.type3d = type3d;
if (IsTownHall(type3d))
{
var th = view.GetComponent<TownHall>() ?? view.AddComponent<TownHall>();
// Та же цена улучшения, что у Ратуши 2D-города (сцена MVP1): 250 дерева, 160 камня, 25 людей, ×1.5 за уровень
th.baseUpgradeCost = new[]
{
new ResourceCost { type = ResourceType.Wood, amount = 250 },
new ResourceCost { type = ResourceType.Stone, amount = 160 },
new ResourceCost { type = ResourceType.Population, amount = 25 },
};
th.costMultiplierPerLevel = 1.5f;
th.maxLevel = 10;
th.currentLevel = Mathf.Max(1, level);
return;
}
view.AddComponent<PlacedBuildingId>().buildingName = GameName(type3d);
switch (type3d)
{
case "Дом": Collector(view, ResourceType.Population, level); break;
case "Ферма": Collector(view, ResourceType.Food, level); break;
case "Лесопилка": Collector(view, ResourceType.Wood, level); break;
case "Каменоломня": Collector(view, ResourceType.Stone, level); break;
case "Шахта": Collector(view, ResourceType.Iron, level); break;
case "Склад": Storage(view, StorageBuilding.Kind.Warehouse, level); break;
case "Амбар": Storage(view, StorageBuilding.Kind.Granary, level); break;
case "Казармы": view.AddComponent<Barracks>().Level = Mathf.Max(1, level); break;
case "Госпиталь": view.AddComponent<Hospital>().Level = Mathf.Max(1, level); break;
case "Золотой рудник": Collector(view, ResourceType.Gold, level); break;
case "Кристалл": Collector(view, ResourceType.Gems, level); break; // рабочих нет, как в 2D
case "Терем": view.AddComponent<HeroCastle>(); break; // окно героя -- BuildingWindow
}
}
static void Collector(GameObject view, ResourceType type, int level)
{
var rc = view.AddComponent<ResourceCollector>();
rc.resourceType = type;
rc.Level = Mathf.Max(1, level);
}
static void Storage(GameObject view, StorageBuilding.Kind kind, int level)
{
var sb = view.AddComponent<StorageBuilding>();
sb.kind = kind;
sb.Level = Mathf.Max(1, level);
}
// Уровень здания (для сохранения и подписи)
public static int LevelOf(GameObject view)
{
var th = view.GetComponent<TownHall>();
if (th != null) return th.currentLevel;
var leveled = view.GetComponent<ILeveledBuilding>();
return leveled != null ? leveled.Level : 1;
}
// HUD 2D-города нарисован на холсте "в камере" (Screen Space - Camera). В 3D-сцене холст стоял бы на расстоянии
// от камеры, и холмы и деревья перекрывали бы кнопки. Ставим холст вплотную к камере -- он всегда поверх 3D.
public static void PutHudInFront(Camera cam)
{
if (cam == null) return;
foreach (var canvas in Object.FindObjectsByType<Canvas>(FindObjectsSortMode.None))
{
if (!canvas.isRootCanvas || canvas.renderMode != RenderMode.ScreenSpaceCamera) continue;
canvas.worldCamera = cam;
canvas.planeDistance = cam.nearClipPlane + 0.05f;
HideWorldSprites(canvas);
}
}
// В HUD есть картинка-спрайт мира (SpriteRenderer) -- плашка с мечом по центру нижней панели. На холсте у
// камеры она рисуется поверх всего HUD, даже поверх открытых окон, а сама картинка без прозрачности
// ("шахматка"). Решение 05.11 (Миша, Володя): в 3D-сцене её убираем, пока не будет новой картинки или кнопки.
static void HideWorldSprites(Canvas canvas)
{
foreach (var sr in canvas.GetComponentsInChildren<SpriteRenderer>(true))
sr.enabled = false;
}
// Награда за расчистку (решение 04.11): +10 дерева за дерево или +10 камня за валун, один раз, в пределах склада
public static void RewardClearing(bool tree)
{
if (GameServices.TryGet(out IEconomy economy)) economy.Add(tree ? ResourceType.Wood : ResourceType.Stone, 10);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3b010df83fccefa4aa9c394f84b8214f
+66
View File
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
// Палец над HUD в 3D-поселении (05.11): «над интерфейсом» -- только если под пальцем то, что нажимают или крутят
// (кнопка, поле, ползунок, прокручиваемое окно) или небольшая панель. Невидимый слой во весь экран
// (затемнение, контейнер безопасной зоны, фон) карту не блокирует -- иначе не работают ни тап, ни зум, ни стройка.
// Такой слой пишется в Console один раз, чтобы его можно было найти и убрать.
public static class City3DInput
{
static readonly List<RaycastResult> hits = new List<RaycastResult>();
static readonly HashSet<string> reported = new HashSet<string>();
public static bool OverHud(Vector2 screen)
{
var es = EventSystem.current;
if (es == null) return false;
// Открыто окно HUD (панель) -- весь ввод его, карта не трогается
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "")
{
if (reported.Add("panel:" + UIPanelManager.Instance.CurrentId))
Debug.Log($"[3D] Открыта панель HUD «{UIPanelManager.Instance.CurrentId}» -- 3D-карта не принимает нажатия, пока её не закрыть");
return true;
}
hits.Clear();
es.RaycastAll(new PointerEventData(es) { position = screen }, hits);
foreach (var h in hits)
{
if (h.gameObject == null) continue;
if (IsInteractive(h.gameObject) || !CoversScreen(h.gameObject)) return true;
if (reported.Add(h.gameObject.name))
Debug.Log($"[3D] Слой интерфейса во весь экран ловит нажатия, 3D-карта его пропускает: «{Path(h.gameObject.transform)}». " +
"Если он не нужен -- выключить у него Raycast Target");
}
return false;
}
static bool IsInteractive(GameObject go)
{
// кнопки, прокрутка, поля, а также всё, что само ловит нажатие (затемнение модального окна закрывает его тапом)
return go.GetComponentInParent<Selectable>() != null || go.GetComponentInParent<ScrollRect>() != null
|| go.GetComponentInParent<IPointerClickHandler>() != null || go.GetComponentInParent<IPointerDownHandler>() != null
|| go.name == "Modal" || go.GetComponentInParent<TMPro.TMP_InputField>() != null;
}
// Занимает почти весь экран (больше 80% по ширине и высоте)
static bool CoversScreen(GameObject go)
{
if (!(go.transform is RectTransform rt)) return false;
var corners = new Vector3[4];
rt.GetWorldCorners(corners);
var canvas = go.GetComponentInParent<Canvas>();
Camera cam = canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay ? canvas.worldCamera : null;
Vector2 a = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 b = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
return Mathf.Abs(b.x - a.x) > Screen.width * 0.8f && Mathf.Abs(b.y - a.y) > Screen.height * 0.8f;
}
static string Path(Transform t)
{
string p = t.name;
for (var x = t.parent; x != null; x = x.parent) p = x.name + "/" + p;
return p;
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c71f24a7cb164064b98f198eec9665d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using Bogatyri.Settlement.Placement;
using UnityEngine;
// ТЕСТОВЫЙ УЧАСТОК 3D-ПОСЕЛЕНИЯ (ТЗ «Система строительства поселения» от 04.11.2026, этап I, прототип).
// Холмы, река, скалы, деревья и валуны строятся из seed -- одинаково при каждом запуске.
// Зоны стройки открываются уровнем Ратуши квадратами от центра (ТЕСТ, число и пороги зон -- на согласование).
// Все числа здесь -- предложение для прототипа, не утверждённый баланс.
public sealed class City3DLayout
{
public const int Size = 48; // клеток по каждой стороне
public const int Seed = 4112026;
public struct Obstacle
{
public string Id;
public bool IsTree; // дерево (иначе валун)
public List<(int x, int y)> Cells;
}
public SettlementMap Map;
public readonly List<Obstacle> Obstacles = new List<Obstacle>();
public readonly List<Vector2> Grass = new List<Vector2>(); // мелкий декор: скрывается под зданиями
// Сколько клеток от центра открывает уровень Ратуши: 1 -- 16 x 16, 2 -- 24 x 24, ... 5 -- весь участок
public static int ZoneHalfSize(int townHallLevel) => Mathf.Min(Size / 2, 8 + 4 * (Mathf.Max(1, townHallLevel) - 1));
// Высота земли в точке (мировые единицы, клетка = 1)
public static float HeightAt(float x, float z)
{
float cx = x - Size / 2f, cz = z - Size / 2f;
float fromCenter = Mathf.Sqrt(cx * cx + cz * cz) / (Size / 2f);
// Холмы: в центре почти ровно, к краям -- выше и круче
float hills = (Mathf.Sin(x * 0.27f) * 0.5f + Mathf.Cos(z * 0.21f) * 0.45f + Mathf.PerlinNoise(x * 0.11f, z * 0.11f) * 0.9f)
* Mathf.Lerp(0.25f, 1.6f, Mathf.Clamp01(fromCenter));
if (IsMountain(x, z)) hills += 2.5f + Mathf.PerlinNoise(x * 0.3f, z * 0.3f) * 2f;
if (IsRiver(x, z)) hills = -0.5f;
return hills;
}
static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f);
static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f;
static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f;
public static City3DLayout Create(PlacementRules rules = null)
{
var layout = new City3DLayout { Map = new SettlementMap(Size, Size, rules) };
var map = layout.Map;
int c = Size / 2;
for (int y = 0; y < Size; y++)
for (int x = 0; x < Size; x++)
{
float px = x + 0.5f, pz = y + 0.5f;
CellKind kind = IsRiver(px, pz) ? CellKind.Water : IsMountain(px, pz) ? CellKind.Mountain : CellKind.Ground;
float dist = Mathf.Max(Mathf.Abs(px - c), Mathf.Abs(pz - c)); // квадратное кольцо от центра
int zone = 0;
for (int lvl = 1; lvl <= 5; lvl++) if (dist < ZoneHalfSize(lvl)) { zone = lvl; break; }
map.SetCell(x, y, kind, Mathf.RoundToInt(HeightAt(px, pz) * 1000f), zone);
}
// Деревья и валуны: не в центральной поляне 10 x 10 (там Ратуша), не на воде и скалах
var rng = new System.Random(Seed);
var taken = new HashSet<int>();
bool Free(int x, int y) => map.InMap(x, y) && map.KindAt(x, y) == CellKind.Ground && !taken.Contains(y * Size + x)
&& (Math.Abs(x - c) > 5 || Math.Abs(y - c) > 5);
int trees = 0, rocks = 0;
for (int attempt = 0; attempt < 4000 && trees < 90; attempt++)
{
int x = rng.Next(Size), y = rng.Next(Size);
if (!Free(x, y)) continue;
taken.Add(y * Size + x);
layout.Obstacles.Add(new Obstacle { Id = "tree_" + (++trees), IsTree = true, Cells = new List<(int, int)> { (x, y) } });
}
for (int attempt = 0; attempt < 4000 && rocks < 22; attempt++)
{
int x = rng.Next(Size - 1), y = rng.Next(Size - 1);
bool big = rng.Next(3) == 0; // каждый третий валун -- 2 x 2
var cells = big ? new List<(int, int)> { (x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1) } : new List<(int, int)> { (x, y) };
bool ok = true;
foreach (var cell in cells) if (!Free(cell.Item1, cell.Item2)) { ok = false; break; }
if (!ok) continue;
foreach (var cell in cells) taken.Add(cell.Item2 * Size + cell.Item1);
layout.Obstacles.Add(new Obstacle { Id = "rock_" + (++rocks), IsTree = false, Cells = cells });
}
foreach (var o in layout.Obstacles) map.AddObstacle(o.Id, o.Cells);
// Трава -- только украшение: не мешает стройке, прячется под зданием и возвращается, когда здание ушло
for (int i = 0; i < 260; i++)
{
float x = (float)rng.NextDouble() * Size, z = (float)rng.NextDouble() * Size;
if (map.KindAt((int)x, (int)z) == CellKind.Ground) layout.Grass.Add(new Vector2(x, z));
}
return layout;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 31e2f86e3dff430ea8a67cc6f1cb4038
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+89
View File
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// Сохранение 3D-поселения: секция "settlement3d" (05.11, Валдемар). 2D-город сохраняется отдельно ("settlement"),
// поэтому 3D-сцена его не трогает. Здания: id, тип, клетка, поворот, уровень, стройка или улучшение, которое идёт;
// Ратуша: уровень и её улучшение; расчищенные деревья и валуны.
// Регистрирует App/GameModules. Пока сцена City3D открыта, данные берутся у неё (Live), иначе -- последние известные.
public sealed class City3DSave : ISaveParticipant
{
[Serializable]
public class Entry
{
public string id;
public string type; // тип 3D-сцены: "Ратуша", "Дом", "Ферма"...
public int x, y, rot;
public int level = 1;
public int timerKind; // 0 -- нет, 1 -- постройка, 2 -- улучшение (BuildTimer.Kind)
public long timerStartMs, timerEndMs;
public int timerTargetLevel;
}
[Serializable]
public class Data
{
public int nextId = 1;
public List<Entry> buildings = new List<Entry>();
public List<string> cleared = new List<string>();
// Здания 2D-города, которым при переносе не нашлось места (зона закрыта, всё занято): ждут и ставятся,
// как только место появится. Не теряются (05.11)
public List<Entry> pendingFrom2D = new List<Entry>();
}
public interface ILive
{
Data CaptureState();
void OnSaveLoaded(); // загрузили другое сохранение или сбросили -- сцена должна перестроиться
}
public static readonly City3DSave Instance = new City3DSave();
public static Data Cached { get; private set; }
public static ILive Live;
public string SaveKey => "settlement3d";
public string Capture()
{
if (Live != null) Cached = Live.CaptureState();
return Cached != null ? JsonUtility.ToJson(Cached) : null;
}
public void Restore(string json)
{
Cached = string.IsNullOrEmpty(json) ? null : JsonUtility.FromJson<Data>(json);
if (Cached != null)
{
if (Cached.buildings == null) Cached.buildings = new List<Entry>();
if (Cached.cleared == null) Cached.cleared = new List<string>();
if (Cached.pendingFrom2D == null) Cached.pendingFrom2D = new List<Entry>();
}
Live?.OnSaveLoaded();
}
public void ResetState()
{
Cached = null;
Live?.OnSaveLoaded();
}
// Запись таймера здания в Entry и обратно
public static void WriteTimer(Entry e, GameObject view)
{
var t = BuildTimer.Of(view);
bool on = t != null && BuildTimer.All.Contains(t);
e.timerKind = on ? (int)t.kind : 0;
e.timerStartMs = on ? t.startMs : 0;
e.timerEndMs = on ? t.endMs : 0;
e.timerTargetLevel = on ? t.targetLevel : 0;
}
// Таймер из файла -- только осмысленный: постройка у 1 уровня, улучшение до уровня выше текущего
public static void ReadTimer(Entry e, GameObject view)
{
bool build = e.timerKind == (int)BuildTimer.Kind.Build && e.level <= 1;
bool upgrade = e.timerKind == (int)BuildTimer.Kind.Upgrade && e.timerTargetLevel > Mathf.Max(1, e.level);
if ((build || upgrade) && e.timerEndMs > 0)
BuildTimer.Begin(view, (BuildTimer.Kind)e.timerKind, Mathf.Max(1, e.timerTargetLevel), e.timerStartMs, e.timerEndMs);
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 037c1ed1fee52f74da1eaaeb4c20e3b1
+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);
}
}
+288 -70
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; }
@@ -22,16 +30,37 @@ public class BuildModeController : MonoBehaviour
public bool IsActive { get; private set; }
// 3D-поселение (сцена City3D): своей 2D-стройки там нет -- кнопка "СТРОЙКА" общего HUD
// открывает стройку 3D-сцены. Задаёт City3DController, пока сцена открыта (05.11).
public static System.Action ExternalToggle;
// То же для кнопки "Перенести" в окне здания: в 3D-сцене здание переносит сама сцена
public static System.Action<GameObject> ExternalMove;
static bool Has2DCity => BuildManager.Instance != null;
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()
{
@@ -39,91 +68,243 @@ public class BuildModeController : MonoBehaviour
SetMode(false);
}
public void ToggleMode() { SetMode(!IsActive); }
public void ToggleMode()
{
if (!Has2DCity && ExternalToggle != null) { ExternalToggle(); return; } // 3D-сцена: своя стройка
SetMode(!IsActive);
}
public void SetMode(bool active)
{
if (!Has2DCity) active = false; // в 3D-сцене 2D-стройки нет (и сетку 2D не создаём)
IsActive = active;
CloseChooser();
ApplySlotVisuals();
CancelGhost();
UpdateButtonLabel();
if (Has2DCity) 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)
{
if (!Has2DCity && ExternalMove != null) { ExternalMove(building); return; } // 3D-сцена
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 +316,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 +326,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 +346,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 +433,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;
}
+53
View File
@@ -0,0 +1,53 @@
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()
{
// Герой-фигурка ходит по клеткам 2D-города (BuildGrid). В 3D-поселении сетки нет -- Терем работает без прогулки
if (Main == this && BuildGrid.Instance != null) 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:
+14 -12
View File
@@ -123,6 +123,8 @@ public static class GameData
public const int MaxPlayerLevel = 60;
// ---------- Основные задания (цепочка) ----------
// Синие кристаллы (регламент 05.11, разд. 3): простое задание -- 1, сложное -- 2 (было 20-250). В ежедневных
// кристаллов нет: кристальное задание -- не чаще раза в 7-14 дней. Числа ТЕСТ
public static readonly List<QuestDef> MainQuests = new List<QuestDef>
{
new QuestDef("m_collect_wood_1", "Собрать 200 дерева", QuestKind.Collect, 200, 30, R(ResourceType.Gold, 200)).Res(ResourceType.Wood),
@@ -130,21 +132,21 @@ public static class GameData
new QuestDef("m_build_1", "Построить первое здание", QuestKind.BuildingsBuilt, 1, 50, R(ResourceType.Wood, 300), R(ResourceType.Stone, 200)),
new QuestDef("m_barracks", "Построить Казармы", QuestKind.BarracksBuilt, 1, 50, R(ResourceType.Food, 300), R(ResourceType.Wood, 200)),
new QuestDef("m_train_10", "Обучить 10 воинов", QuestKind.TroopsTrained, 10, 50, R(ResourceType.Food, 400)),
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 30)),
new QuestDef("m_battle_1", "Выиграть первый бой", QuestKind.BattlesWon, 1, 60, R(ResourceType.Gems, 1)),
new QuestDef("m_world_send", "Отправить армию в поход на карте мира", QuestKind.WorldMarches, 1, 60, R(ResourceType.Food, 500)),
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 50), R(ResourceType.Gold, 500)),
new QuestDef("m_world_win", "Разграбить поселение на карте мира", QuestKind.WorldWins, 1, 100, R(ResourceType.Gems, 2), R(ResourceType.Gold, 500)),
new QuestDef("m_hero_3", "Герой 3 уровня", QuestKind.HeroLevel, 3, 60, R(ResourceType.Gold, 500)),
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 50), R(ResourceType.Stone, 500)),
new QuestDef("m_th_2", "Ратуша 2 уровня", QuestKind.TownHallLevel, 2, 100, R(ResourceType.Gems, 1), R(ResourceType.Stone, 500)),
new QuestDef("m_build_3", "Построить 3 здания", QuestKind.BuildingsBuilt, 3, 80, R(ResourceType.Wood, 600), R(ResourceType.Iron, 200)),
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 20)),
new QuestDef("m_arena_1", "Сразиться на арене", QuestKind.ArenaFights, 1, 60, R(ResourceType.Gems, 1)),
new QuestDef("m_collect_stone_1", "Собрать 1000 камня", QuestKind.Collect, 1000, 80, R(ResourceType.Gold, 800)).Res(ResourceType.Stone),
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 30)),
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 60)),
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 100)),
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 100), R(ResourceType.Iron, 800)),
new QuestDef("m_train_100", "Обучить 100 воинов", QuestKind.TroopsTrained, 100, 120, R(ResourceType.Food, 1500), R(ResourceType.Gems, 1)),
new QuestDef("m_battle_5", "Выиграть 5 боёв", QuestKind.BattlesWon, 5, 150, R(ResourceType.Gems, 1)),
new QuestDef("m_player_5", "Достигнуть 5 уровня", QuestKind.PlayerLevel, 5, 100, R(ResourceType.Gems, 1)),
new QuestDef("m_th_3", "Ратуша 3 уровня", QuestKind.TownHallLevel, 3, 200, R(ResourceType.Gems, 2), R(ResourceType.Iron, 800)),
new QuestDef("m_hero_10", "Герой 10 уровня", QuestKind.HeroLevel, 10, 200, R(ResourceType.Gold, 3000)),
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 150)),
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 250)),
new QuestDef("m_battle_15", "Выиграть 15 боёв", QuestKind.BattlesWon, 15, 300, R(ResourceType.Gems, 2)),
new QuestDef("m_th_5", "Ратуша 5 уровня", QuestKind.TownHallLevel, 5, 400, R(ResourceType.Gems, 2)),
};
// ---------- Ежедневные задания (сбрасываются каждый день) ----------
@@ -154,8 +156,8 @@ public static class GameData
new QuestDef("d_wood", "Собрать 300 дерева", QuestKind.Collect, 300, 20, R(ResourceType.Food, 200)).Res(ResourceType.Wood),
new QuestDef("d_food", "Собрать 300 еды", QuestKind.Collect, 300, 20, R(ResourceType.Wood, 200)).Res(ResourceType.Food),
new QuestDef("d_train", "Обучить 20 воинов", QuestKind.TroopsTrained, 20, 25, R(ResourceType.Iron, 150)),
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gems, 10)),
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gems, 10)),
new QuestDef("d_battle", "Выиграть 1 бой", QuestKind.BattlesWon, 1, 30, R(ResourceType.Gold, 200)),
new QuestDef("d_arena", "2 боя на арене", QuestKind.ArenaFights, 2, 30, R(ResourceType.Gold, 200)),
new QuestDef("d_world", "Отправить поход на карте мира", QuestKind.WorldMarches, 1, 25, R(ResourceType.Gold, 200)),
};
+195 -44
View File
@@ -144,6 +144,8 @@ public class GameProgress : MonoBehaviour
}
if (Data.claimedQuests == null) Data.claimedQuests = new List<string>();
if (Data.claimedDaily == null) Data.claimedDaily = new List<string>();
if (Data.crystalWaiting == null) Data.crystalWaiting = new List<string>();
crystals.Load(Data.crystalWaiting);
if (Data.mail == null) Data.mail = new List<MailItem>();
if (Data.shopIds == null) Data.shopIds = new List<string>();
if (Data.shopCounts == null) Data.shopCounts = new List<int>();
@@ -307,24 +309,76 @@ public class GameProgress : MonoBehaviour
return true;
}
// Выдать награду. Синие кристаллы -- не сверх предела хранилища (регламент 05.11): не поместившиеся приходят
// письмом «Кристаллы ждут места» и забираются из него, когда место появится. Ничего не теряется
public void Grant(RewardItem[] rewards, int xp = 0, bool toast = true)
{
if (ResourceManager.Instance != null && rewards != null)
int gems = GemsIn(rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
var given = new List<RewardItem>(WithoutGems(rewards));
if (fit > 0) given.Add(new RewardItem(ResourceType.Gems, fit));
if (ResourceManager.Instance != null)
{
suppressTracking++;
foreach (var r in rewards) ResourceManager.Instance.AddResource(r.type, r.amount);
foreach (var r in given) ResourceManager.Instance.AddResource(r.type, r.amount);
suppressTracking--;
}
if (left > 0)
SendMail("Кристаллы ждут места",
$"Хранилище синих кристаллов заполнено ({GemsHave:0} / {GemsCap:0}). {left} кристаллов ждут: потратьте кристаллы и заберите их из этого письма.",
new RewardItem(ResourceType.Gems, left));
if (xp > 0) AddXp(xp);
if (toast)
{
string text = GameData.RewardsText(rewards);
string text = GameData.RewardsText(given.ToArray());
if (xp > 0) text += (text.Length > 0 ? ", " : "") + xp + " опыта";
if (left > 0) text += (text.Length > 0 ? ". " : "") + $"Ещё {left} кристаллов ждут места -- в почте";
if (text.Length > 0) UIKit.Toast("Получено: " + text);
}
MarkChanged();
}
// ---------------- Синие кристаллы при пределе 50 (регламент 05.11, ред. 1.1; правила -- CrystalRewards) ----------------
readonly CrystalRewards crystals = new CrystalRewards();
// Нет склада (сцена без ResourceManager) -- места нет: кристаллы ждут, а не теряются
public static float GemsHave => ResourceManager.Instance != null ? ResourceManager.Instance.GetResource(ResourceType.Gems) : 0f;
public static float GemsCap => ResourceManager.Instance != null ? ResourceManager.Instance.GetCapacity(ResourceType.Gems) : 0f;
static int GemsIn(IEnumerable<RewardItem> rewards)
{
int n = 0;
if (rewards != null) foreach (var r in rewards) if (r != null && r.type == ResourceType.Gems) n += Mathf.Max(0, r.amount);
return n;
}
static RewardItem[] WithoutGems(IEnumerable<RewardItem> rewards)
{
var list = new List<RewardItem>();
if (rewards != null) foreach (var r in rewards) if (r != null && r.type != ResourceType.Gems) list.Add(r);
return list.ToArray();
}
static string QuestKey(QuestDef q, bool daily) => (daily ? "d:" : "m:") + q.id;
// Кристаллы награды задания, которые ждут места (0 -- не ждут)
public int QuestGemsWaiting(QuestDef q, bool daily) => crystals.Left(QuestKey(q, daily));
// Начислить кристаллы награды key: сколько помещается, остаток ждёт под этим key (сохраняется)
int GiveQuestGems(string key, int amount)
{
int give = crystals.Claim(key, amount, GemsHave, GemsCap);
if (give > 0 && ResourceManager.Instance != null)
{
suppressTracking++;
ResourceManager.Instance.AddResource(ResourceType.Gems, give);
suppressTracking--;
}
Data.crystalWaiting = crystals.ToLines();
MarkChanged();
return give;
}
// =====================================================================
// ОТСЛЕЖИВАНИЕ СБОРА РЕСУРСОВ (для заданий и статистики)
// =====================================================================
@@ -455,13 +509,42 @@ public class GameProgress : MonoBehaviour
public bool IsQuestClaimed(QuestDef q, bool daily) { return (daily ? Data.claimedDaily : Data.claimedQuests).Contains(q.id); }
public bool IsQuestDone(QuestDef q, bool daily) { return QuestProgress(q, daily) >= q.target; }
// Забрать награду задания. Кристаллы, которым нет места, ждут в задании («Забрать кристаллы»); пока ждут
// CrystalRewards.MaxWaiting наград, новое задание с кристаллами, которые не помещаются, не выдаётся (регламент 05.11)
public void ClaimQuest(QuestDef q, bool daily)
{
if (IsQuestClaimed(q, daily) || !IsQuestDone(q, daily)) return;
string key = QuestKey(q, daily);
if (IsQuestClaimed(q, daily))
{
if (crystals.Left(key) <= 0) return;
int got = GiveQuestGems(key, 0); // дозабрать остаток
int still = crystals.Left(key);
UIKit.Toast(got > 0 ? $"Получено: {got} кристаллов" + (still > 0 ? $". Ещё {still} ждут места" : "")
: $"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0})");
return;
}
if (!IsQuestDone(q, daily)) return;
int gems = GemsIn(q.rewards);
if (!crystals.CanStart(key, gems, GemsHave, GemsCap))
{
UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}), и уже ждут {CrystalRewards.MaxWaiting} награды. " +
"Потратьте кристаллы или заберите ждущие -- награда этого задания сохранится");
return;
}
(daily ? Data.claimedDaily : Data.claimedQuests).Add(q.id);
Data.questsDone++;
AddEventPoints(20);
Grant(q.rewards, q.xp);
var rest = WithoutGems(q.rewards);
Grant(rest, q.xp, false);
int given = gems > 0 ? GiveQuestGems(key, gems) : 0, left = crystals.Left(key);
var shown = new List<RewardItem>(rest);
if (given > 0) shown.Add(new RewardItem(ResourceType.Gems, given));
string text = GameData.RewardsText(shown.ToArray());
if (q.xp > 0) text += (text.Length > 0 ? ", " : "") + q.xp + " опыта";
if (left > 0) text += $". Ещё {left} кристаллов ждут места -- «Забрать» в заданиях";
UIKit.Toast("Получено: " + text);
}
public int ClaimableQuestsCount
@@ -471,6 +554,12 @@ public class GameProgress : MonoBehaviour
int n = 0;
foreach (var q in GameData.MainQuests) if (!IsQuestClaimed(q, false) && IsQuestDone(q, false)) n++;
foreach (var q in GameData.DailyQuests) if (!IsQuestClaimed(q, true) && IsQuestDone(q, true)) n++;
// кристаллы, которые ждали места, -- когда место появилось
if (crystals.WaitingCount > 0 && CrystalRewards.Free(GemsHave, GemsCap) > 0)
{
foreach (var q in GameData.MainQuests) if (IsQuestClaimed(q, false) && QuestGemsWaiting(q, false) > 0) n++;
foreach (var q in GameData.DailyQuests) if (IsQuestClaimed(q, true) && QuestGemsWaiting(q, true) > 0) n++;
}
return n;
}
}
@@ -525,11 +614,27 @@ public class GameProgress : MonoBehaviour
public void ReadMail(MailItem m) { if (m != null && !m.read) { m.read = true; MarkChanged(); } }
public void ClaimMail(MailItem m, bool toast = true)
// Забрать подарок письма. Кристаллы -- сколько помещается; остаток остаётся в этом же письме (регламент 05.11).
// false -- в письме остались кристаллы, которым нет места
public bool ClaimMail(MailItem m, bool toast = true)
{
if (m == null || m.claimed || !m.HasRewards) return;
m.claimed = true; m.read = true;
Grant(m.rewards.ToArray(), 0, toast);
if (m == null || m.claimed || !m.HasRewards) return true;
m.read = true;
int gems = GemsIn(m.rewards), fit = CrystalRewards.Fits(gems, GemsHave, GemsCap), left = gems - fit;
var give = new List<RewardItem>(WithoutGems(m.rewards));
if (give.Count == 0 && fit == 0)
{
if (toast) UIKit.Toast($"Нет места для кристаллов ({GemsHave:0} / {GemsCap:0}) -- подарок подождёт в письме");
MarkChanged();
return false;
}
if (fit > 0) give.Add(new RewardItem(ResourceType.Gems, fit));
m.rewards.Clear();
if (left > 0) m.rewards.Add(new RewardItem(ResourceType.Gems, left)); // остаток ждёт в письме
else m.claimed = true;
Grant(give.ToArray(), 0, toast); // всё помещается: Grant ничего не отложит
if (toast && left > 0) UIKit.Toast($"Ещё {left} кристаллов ждут места -- в этом письме");
return left == 0;
}
public void DeleteMail(MailItem m)
@@ -542,13 +647,16 @@ public class GameProgress : MonoBehaviour
public void ClaimAllMail()
{
var all = new List<RewardItem>();
foreach (var m in Data.mail)
int gifts = 0, waiting = 0;
foreach (var m in Data.mail.ToArray())
{
m.read = true;
if (m.HasRewards && !m.claimed) { m.claimed = true; all.AddRange(m.rewards); }
if (!m.HasRewards || m.claimed) continue;
gifts++;
if (!ClaimMail(m, false)) waiting++;
}
if (all.Count > 0) Grant(all.ToArray()); else UIKit.Toast("Все письма прочитаны");
if (gifts == 0) UIKit.Toast("Все письма прочитаны");
else UIKit.Toast(waiting == 0 ? "Подарки получены" : $"Подарки получены. В {waiting} письмах кристаллы ждут места");
MarkChanged();
}
@@ -622,6 +730,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 +743,105 @@ 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);
army.ProcessMissing(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();
}
// Потери в бою: убираем процент от каждого типа войск
+4 -1
View File
@@ -24,6 +24,7 @@ public class TrainingOrder
public int count;
public long startTicks;
public long endTicks;
public bool recruited; // под заказ взяты люди из жителей (модуль Населения). false -- старый заказ, людей не брали
}
[Serializable]
@@ -68,6 +69,8 @@ public class ProgressData
public List<string> claimedQuests = new List<string>();
public int dailyDay = -1;
public List<string> claimedDaily = new List<string>();
// Кристаллы наград заданий, которым не хватило места (регламент 05.11): "m:<id задания>|сколько"
public List<string> crystalWaiting = new List<string>();
public float[] dailyCollected = new float[8];
public int dailyBattles;
public int dailyTroops;
@@ -96,7 +99,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;
+12 -1
View File
@@ -32,7 +32,9 @@ public class QuestsPanel : BasePanel
var shown = new List<QuestDef>();
int claimedCount = 0;
// Сначала готовые к получению, потом в процессе. В основных -- не больше 6 активных.
// Сначала кристаллы, которые ждут места (регламент 05.11), потом готовые к получению, потом в процессе.
// В основных -- не больше 6 активных.
foreach (var q in source) if (GP.IsQuestClaimed(q, daily) && GP.QuestGemsWaiting(q, daily) > 0) shown.Add(q);
foreach (var q in source) if (!GP.IsQuestClaimed(q, daily) && GP.IsQuestDone(q, daily)) shown.Add(q);
foreach (var q in source)
{
@@ -62,6 +64,15 @@ public class QuestsPanel : BasePanel
UIKit.Bar(card, (float)progress / q.target, done ? UIKit.Theme.Green : UIKit.Theme.Gold, 30, progress + " / " + q.target);
var row = UIKit.Row(card, 80, 12);
int waiting = GP.QuestGemsWaiting(q, daily);
if (waiting > 0)
{
// Награда получена, кроме кристаллов: им не было места (хранилище 50). Ждут, не сгорают
var wait = UIKit.Text(row, $"Ждут места: {waiting} кристаллов (хранилище {GameProgress.GemsHave:0} / {GameProgress.GemsCap:0})", 24, UIKit.Theme.Gold);
UIKit.Size(wait, -1, -1, 1);
UIKit.Button(row, "Забрать", UIKit.Theme.Green, () => GP.ClaimQuest(q, daily), 220, 80);
return;
}
var reward = UIKit.Text(row, "Награда: " + GameData.RewardsText(q.rewards) + ", " + q.xp + " опыта", 24, UIKit.Theme.TextDim);
UIKit.Size(reward, -1, -1, 1);
var b = UIKit.Button(row, done ? "Забрать" : "В процессе", done ? UIKit.Theme.Green : UIKit.Theme.Gray,
+230 -44
View File
@@ -4,7 +4,8 @@ using UnityEngine.UI;
using TMPro;
// ВОЙСКА: армия, очередь обучения (идёт и когда игра закрыта), найм воинов за ресурсы,
// ускорение за кристаллы. Новые типы войск открываются уровнем Ратуши.
// ускорение за кристаллы. Найм -- карточки войск в ряд (листаются вбок): иконка, сила, цена,
// против кого силён и слаб (из правил боя). Новые типы войск открывают уровни Ратуши и Казарм.
public class TroopsPanel : BasePanel
{
readonly List<Image> queueBars = new List<Image>();
@@ -13,6 +14,23 @@ public class TroopsPanel : BasePanel
static readonly int[] Amounts = { 1, 10, 50 };
int shownBarracks; // сколько Казарм было, когда окно рисовалось: достроились -- перерисовать
TextMeshProUGUI barracksBuildingLeft; // «Осталось: 0:45», пока Казармы строятся
// Казармы, которые ещё строятся: сколько секунд до ближайшего конца; -1 -- таких нет
static double BarracksConstructionSecondsLeft()
{
double best = -1;
foreach (var t in BuildTimer.All)
{
if (t == null || !t.IsConstruction) continue;
var id = t.GetComponent<PlacedBuildingId>();
if (id == null || id.buildingName != BarracksBootstrap.BarracksName) continue;
if (best < 0 || t.SecondsLeft < best) best = t.SecondsLeft;
}
return best;
}
protected override void Build(RectTransform root)
{
var d = GP.Data;
@@ -24,36 +42,93 @@ 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);
}
if (hurt.MissingTotal > 0)
{
long windowEnd = 0;
foreach (var m in hurt.Missing) windowEnd = System.Math.Max(windowEnd, m.EndMs);
ProfilePanel.KV(army, "Пропали без вести", hurt.MissingTotal.ToString("N0") + " (могут вернуться ещё " +
GameData.FormatTime(System.Math.Max(0, windowEnd - GameClock.UnixNowMs) / 1000.0) + ")");
}
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);
// --- Нет казарм -- нанимать негде ---
shownBarracks = Barracks.Count;
barracksBuildingLeft = null;
if (Barracks.Count == 0)
{
var warn = UIKit.Card(list, new Color(0.35f, 0.18f, 0.12f));
UIKit.Text(warn, "Нанимать войска можно только в Казармах. Постройте их на свободном участке.", 28, UIKit.Theme.Text);
UIKit.Button(warn, "ПОСТРОИТЬ КАЗАРМЫ", UIKit.Theme.Green, () =>
double left = BarracksConstructionSecondsLeft();
if (left >= 0)
{
if (BuildModeController.Instance != null) BuildModeController.Instance.SetMode(true);
else UIKit.Toast("Нажмите кнопку СТРОЙКА");
}, -1, 96, 32);
// Казармы поставлены, но ещё строятся (05.11: в 3D их было не отличить от готовых)
UIKit.Text(warn, "Казармы строятся. Когда стройка закончится, здесь можно будет нанимать войска.", 28, UIKit.Theme.Text);
barracksBuildingLeft = UIKit.Text(warn, "Осталось: " + GameData.FormatTime(left), 28, UIKit.Theme.Text);
}
else
{
UIKit.Text(warn, "Нанимать войска можно только в Казармах. Постройте их на свободном участке.", 28, UIKit.Theme.Text);
UIKit.Button(warn, "ПОСТРОИТЬ КАЗАРМЫ", UIKit.Theme.Green, () =>
{
if (City3DController.Instance != null) City3DController.Instance.PlaceBuilding("Казармы"); // 3D-город
else if (BuildModeController.Instance != null) BuildModeController.Instance.SetMode(true);
else UIKit.Toast("Нажмите кнопку СТРОЙКА");
}, -1, 96, 32);
}
}
// --- Очередь обучения ---
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 +139,160 @@ 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++)
if (Barracks.Count != shownBarracks) { RequestRebuild(); return; } // Казармы достроились (или снесены)
if (barracksBuildingLeft != null)
{
var o = d.training[i];
double left = BarracksConstructionSecondsLeft();
barracksBuildingLeft.text = "Осталось: " + GameData.FormatTime(System.Math.Max(0, left));
}
var training = GP.Training;
for (int i = 0; i < queueBars.Count && i < training.Count; 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
+640 -12
View File
@@ -8,6 +8,39 @@ 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;
}
}
// Пропавшие без вести (решения 01.11, п. 5). Доля возвращающихся и формула -- НЕ УТВЕРЖДЕНЫ (ТЕСТ, таблица HospitalConfig)
public sealed class MissingRules
{
public int ReturnPercent = 50; // сколько пропавших вернётся (утверждён диапазон 10-90%, формула открыта)
public int WoundedPercent = 30; // из вернувшихся -- раненые (утверждено: 70% здоровыми, 30% ранеными)
public int WindowGameHours = 6; // окно возвращения после обратного пути, ИГРОВЫХ часов
public long GameHourMs = 5 * 60 * 1000;
public int MaxGroups = 4; // возвращаются 2-4 группами (меньше, если пропавших мало)
public static MissingRules Default => new MissingRules();
}
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
@@ -19,8 +52,54 @@ 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 class MissingEvent
{
public int id, marchId;
public long windowStartMs, endMs;
public int[] remaining = new int[Types]; // ещё не вернулись и не списаны
public List<MissingGroup> groups = new List<MissingGroup>();
}
private class MissingGroup
{
public int index;
public long timeMs;
public int[] healthy = new int[Types], wounded = new int[Types];
}
private readonly List<MissingEvent> missing = new List<MissingEvent>();
private readonly List<MissingInfo> missingView = new List<MissingInfo>();
private readonly HashSet<int> missingMarches = new HashSet<int>(); // по каким походам пропавшие уже отмечены
private int nextMissingId = 1;
private readonly MissingRules missingRules;
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 +107,21 @@ 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, MissingRules missingRules = null)
{
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
this.hospital = hospital ?? HospitalRules.Default;
this.missingRules = missingRules ?? MissingRules.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 +142,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 +183,417 @@ 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 GetAllTroops(int type)
{
if (type < 0 || type >= Types) return 0;
int n = troops[type] + woundedWaiting[type];
foreach (var m in marches) n += m.units[type];
foreach (var b in healing) if (b.type == type) n += b.count;
return n;
}
// ---------------- Пропавшие без вести ----------------
public int MissingTotal
{
get { int n = 0; foreach (var e in missing) foreach (var r in e.remaining) n += r; return n; }
}
public IReadOnlyList<MissingInfo> Missing
{
get
{
missingView.Clear();
foreach (var e in missing)
{
int n = 0;
foreach (var r in e.remaining) n += r;
missingView.Add(new MissingInfo { Id = e.id, Count = n, WindowStartMs = e.windowStartMs, EndMs = e.endMs });
}
return missingView;
}
}
public bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs)
{
if (missingUnits == null || missingMarches.Contains(marchId)) return false;
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
var e = new MissingEvent { id = nextMissingId++, marchId = marchId };
int total = 0;
for (int i = 0; i < Types && i < missingUnits.Length; i++)
{
int n = Math.Max(0, Math.Min(missingUnits[i], entry.units[i]));
entry.units[i] -= n; // из похода -- в пропавшие: один солдат в одном месте
e.remaining[i] = n;
total += n;
}
if (total == 0) { nextMissingId--; return false; }
e.windowStartMs = battleMs + Math.Max(0, travelMs);
e.endMs = e.windowStartMs + Math.Max(0, missingRules.WindowGameHours) * missingRules.GameHourMs;
PlanGroups(e, battleMs);
missing.Add(e);
missingMarches.Add(marchId);
Changed();
return true;
}
// Кто вернётся и когда: доля возвращающихся по типам (дробная часть -- бросок), 2-4 группы в окне,
// в каждой -- 70% здоровых, 30% раненых (дробная часть -- бросок). Тип войск сохраняется.
void PlanGroups(MissingEvent e, long seedTime)
{
ulong seed = (ulong)e.marchId * 0x9E3779B97F4A7C15UL ^ (ulong)seedTime;
int roll = 0;
bool Chance(long num, long den) => den > 0 && (long)(Next(seed, roll++) % (ulong)den) < num;
var back = new int[Types];
int returning = 0;
for (int i = 0; i < Types; i++)
{
long num = (long)e.remaining[i] * Math.Max(0, Math.Min(100, missingRules.ReturnPercent));
back[i] = (int)(num / 100) + (Chance(num % 100, 100) ? 1 : 0);
returning += back[i];
}
if (returning == 0) return;
int groups = Math.Max(1, Math.Min(Math.Max(2, missingRules.MaxGroups), returning)); // мало вернувшихся -- групп меньше
if (returning >= 2) groups = Math.Max(2, Math.Min(groups, 2 + (int)(Next(seed, roll++) % (ulong)(Math.Max(2, missingRules.MaxGroups) - 1))));
long window = Math.Max(1, e.endMs - e.windowStartMs);
for (int g = 0; g < groups; g++)
e.groups.Add(new MissingGroup { index = g + 1, timeMs = e.windowStartMs + window * (g + 1) / (groups + 1) });
// Раздаём вернувшихся по группам по очереди, внутри группы -- здоровые и раненые
for (int i = 0; i < Types; i++)
for (int k = 0; k < back[i]; k++)
{
var grp = e.groups[(k + i) % groups];
if (Chance(Math.Max(0, Math.Min(100, missingRules.WoundedPercent)), 100)) grp.wounded[i]++;
else grp.healthy[i]++;
}
}
static ulong Next(ulong seed, int n)
{
ulong x = seed + 0x9E3779B97F4A7C15UL * (ulong)(n + 1);
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
public int ProcessMissing(long nowMs)
{
int done = 0;
bool changed = false;
for (int k = missing.Count - 1; k >= 0; k--)
{
var e = missing[k];
e.groups.Sort((a, b) => a.timeMs.CompareTo(b.timeMs));
while (e.groups.Count > 0 && e.groups[0].timeMs <= nowMs && e.groups[0].timeMs <= e.endMs)
{
var g = e.groups[0];
e.groups.RemoveAt(0); // группа возвращается один раз
int healthy = 0, wounded = 0;
bool anyWounded = false;
for (int i = 0; i < Types; i++)
{
int h = Math.Min(g.healthy[i], e.remaining[i]);
int w = Math.Min(g.wounded[i], e.remaining[i] - h);
e.remaining[i] -= h + w;
troops[i] += h; // исходной армии может уже не быть -- в резерв поселения
woundedWaiting[i] += w; // раненые -- в госпиталь по обычным правилам
healthy += h; wounded += w;
if (w > 0) anyWounded = true;
}
if (anyWounded) { ProcessHealing(g.timeMs); FillBeds(g.timeMs); }
done++;
changed = true;
EventBus.Publish(new MissingReturnedEvent { MissingId = e.id, Group = g.index, Healthy = healthy, Wounded = wounded, TimeMs = g.timeMs });
}
if (nowMs >= e.endMs)
{
int left = 0;
foreach (var r in e.remaining) left += r;
missing.RemoveAt(k); // окно кончилось: кто не вернулся -- списан, места освобождены
changed = true;
EventBus.Publish(new MissingWrittenOffEvent { MissingId = e.id, Count = left, TimeMs = e.endMs });
}
}
if (changed) Changed();
return done;
}
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,
Missing = MissingTotal });
}
// ---------------- Перенос из старого progress.json ----------------
@@ -112,12 +604,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 +623,16 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// ---------------- Сохранение (секция "army") ----------------
// Модуль без Unity, поэтому формат -- простые строки:
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
// train=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
// trainNext=<номер следующего заказа>
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
// healNext=<номер следующей группы лечения>
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
public string SaveKey => "army";
@@ -137,15 +640,53 @@ 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');
foreach (var e in missing)
{
sb.Append("missing=").Append(string.Join("|", new[]
{
N(e.id), N(e.marchId), e.windowStartMs.ToString(CultureInfo.InvariantCulture), e.endMs.ToString(CultureInfo.InvariantCulture), Join(e.remaining)
})).Append('\n');
foreach (var g in e.groups)
sb.Append("mgroup=").Append(string.Join("|", new[]
{
N(e.id), N(g.index), g.timeMs.ToString(CultureInfo.InvariantCulture), Join(g.healthy), Join(g.wounded)
})).Append('\n');
}
foreach (var id in missingMarches) sb.Append("missingMarch=").Append(N(id)).Append('\n');
sb.Append("missingNext=").Append(N(nextMissingId)).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;
missing.Clear();
missingMarches.Clear();
nextMissingId = 1;
RestoredFromSave = true;
if (!string.IsNullOrEmpty(data))
@@ -158,25 +699,112 @@ 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("missing="))
{
string[] p = line.Substring("missing=".Length).Split('|');
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mid)
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mmarch)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mstart)
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mend)
&& !missing.Exists(x => x.id == mid))
{
missing.Add(new MissingEvent { id = mid, marchId = mmarch, windowStartMs = mstart, endMs = mend, remaining = Parse(p[4]) });
nextMissingId = Math.Max(nextMissingId, mid + 1);
}
}
else if (line.StartsWith("mgroup="))
{
string[] p = line.Substring("mgroup=".Length).Split('|');
MissingEvent owner = null;
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gid))
owner = missing.Find(x => x.id == gid); // строки missing= идут раньше своих групп
if (owner != null && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gidx)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long gtime)
&& !owner.groups.Exists(x => x.index == gidx))
owner.groups.Add(new MissingGroup { index = gidx, timeMs = gtime, healthy = Parse(p[3]), wounded = Parse(p[4]) });
}
else if (line.StartsWith("missingMarch=")
&& int.TryParse(line.Substring("missingMarch=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mm))
missingMarches.Add(mm);
else if (line.StartsWith("missingNext=")
&& int.TryParse(line.Substring("missingNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mnext))
nextMissingId = Math.Max(nextMissingId, mnext);
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;
missing.Clear();
missingMarches.Clear();
nextMissingId = 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,53 @@
// ПРАВИЛА БОЯ: все числа боевой системы в одном месте. Документ: 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 = 8; // 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);
// 8 -- пропавшие без вести (п. 5)
// Сила одного воина каждого типа. В игре -- из 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%
// Пропавшие без вести (решения 01.11, п. 5): доля выбывших, получающих этот статус, в десятых долях процента.
// НЕ УТВЕРЖДЕНА командой -- по умолчанию 0 (механика выключена). Остальные выбывшие -- погибшие и раненые, как выше.
public int MissingSharePermille = 0;
// Состояния (утверждено 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:
+409 -11
View File
@@ -1,26 +1,424 @@
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);
// Выбывшие: сначала пропавшие без вести (решения 01.11, п. 5), остальные -- погибшие и раненые (решения 31.10, п. 7):
// доля погибших -- от соотношения сил своей стороны. Солдат -- ровно в одном статусе
int[] attMissing = Missing(attOut, request.Seed, side: 1, rounding);
int[] defMissing = Missing(defOut0, request.Seed, side: 2, rounding);
int[] attRest = new int[TroopType.Count], defRest = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) { attRest[i] = attOut[i] - attMissing[i]; defRest[i] = defOut0[i] - defMissing[i]; }
int[] attDead = Dead(attRest, attPower, defPower, request.Seed, side: 1, rounding);
int[] defDead = Dead(defRest, 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] = attRest[i] - attDead[i];
defWounded[i] = defRest[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] - attOut[i];
defAlive[i] = def[i] - defOut0[i];
defOut[i] = defOut0[i];
}
return new BattleOutcome
{
Victory = victory,
AttackerLosses = losses,
DefenderLosses = losses,
Survivors = attackers - losses
BattleId = request.Seed,
RulesVersion = rules.Version,
AttackerLosses = Sum(attOut),
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,
AttackerMissingUnits = attMissing,
DefenderMissingUnits = defMissing,
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;
}
// Пропавшие без вести среди выбывших каждого типа: доля из правил (не утверждена; 0 -- никого), дробная часть -- бросок
// из своего потока. Прогноз: Floor -- меньше пропавших, Ceiling -- больше
int[] Missing(int[] outUnits, long seed, int side, Rounding rounding)
{
var missing = new int[TroopType.Count];
int share = Math.Max(0, Math.Min(1000, rules.MissingSharePermille));
if (share == 0) return missing;
for (int i = 0; i < TroopType.Count; i++)
{
if (outUnits[i] <= 0) continue;
long num = (long)outUnits[i] * share;
long whole = num / 1000, rest = num % 1000;
bool extra = rest != 0 && (rounding == Rounding.Ceiling
|| rounding == Rounding.Roll && (LossRoll(seed, side + 24, i) * 1000UL >> 32) < (ulong)rest);
missing[i] = (int)Math.Min(outUnits[i], whole + (extra ? 1 : 0));
}
return missing;
}
// Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (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,113 @@ public interface IPopulation
}
// ---------------- Армия: войска ----------------
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
public struct MissingInfo
{
public int Id;
public int Count; // ещё не вернулись и не списаны
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
public long EndMs; // конец окна: кто не вернулся -- списан
}
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
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 GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4)
// ---- Пропавшие без вести (решения 01.11, п. 5) ----
// Не воюют и не едят, но занимают места в населении до возвращения или списания.
int MissingTotal { get; }
IReadOnlyList<MissingInfo> Missing { get; }
// Бой посчитал пропавших: забрать их из похода в группу пропавших. battleMs -- конец боя, travelMs -- обратный путь.
// Только известный поход и только один раз на поход; больше, чем ушло, не забрать.
bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs);
// Вернуть группы, время которых наступило (и офлайн), и списать тех, чьё окно кончилось. Сколько групп вернулось.
int ProcessMissing(long nowMs);
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 +212,196 @@ 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);
// Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться
bool TryStartRecon(string targetId, int soldiers, int level, 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; // точная численность -- разведка показывает игроку только примерную
}
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
public enum ReconTargetKind { None, Settlement, Castle, Camp }
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
public struct ReconTarget
{
public string Id;
public string Name;
public ReconTargetKind Kind;
public int[] Units; // гарнизон по типам (TroopType)
public bool Fortified; // защитники за стенами
public bool HasHero;
public string[] ResourceNames; // что можно взять (имена ResourceType)
public int[] ResourceAmounts;
}
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);
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
bool TryGetReconTarget(string targetId, out ReconTarget target);
}
// ---------------- Разведка (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; // когда видели последний раз
}
// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения.
// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15%
public struct ReconReport
{
public string Id; // устойчивый: "recon_<номер похода>"
public string TargetId;
public string TargetName;
public int Level; // 2 или 3
public long TimeMs; // когда закончилось наблюдение (по часам игры)
public int[] Units; // гарнизон по типам на момент отчёта
public bool Fortified;
public bool HasHero;
public string Modifiers; // уровень III: основные боевые модификаторы защиты
public string[] ResourceNames; // уровень III: оценка ресурсов
public int[] ResourceMin, ResourceMax;
}
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); // досчитать наблюдение до момента (в том числе офлайн)
// ---- М2: целенаправленная разведка ----
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
// 0 -- цель нельзя разведать с отчётом
long ReconDurationMs(string targetId, int level);
// Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false)
bool CompleteRecon(string reportId, string targetId, int level, long timeMs);
IReadOnlyList<ReconReport> Reports { get; }
bool TryGetLatestReport(string targetId, out ReconReport report);
}
// ---------------- Боевая система: урон, потери, исход ----------------
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[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
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,64 @@ public struct TroopsChangedEvent
{
public int AtHome;
public int InMarches;
public int Garrison; // из AtHome -- в гарнизоне
public int Wounded; // раненые: в госпитале или ждут места (не в AtHome)
public int Missing; // пропавшие без вести: занимают места в населении, но не едят и не воюют
}
// Пропавшие без вести вернулись (часть группы боя): здоровые -- в город, раненые -- в госпиталь. Один раз на группу
public struct MissingReturnedEvent
{
public int MissingId;
public int Group;
public int Healthy;
public int Wounded;
public long TimeMs;
}
// Окно возвращения кончилось: кто не вернулся -- списан, места в населении освобождены
public struct MissingWrittenOffEvent
{
public int MissingId;
public int Count;
public long TimeMs;
}
// Раненые вылечены и вернулись в город. Один раз на группу лечения (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 +137,15 @@ 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 int Missing; // пропали без вести в бою -- могут вернуться сами
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
}
@@ -121,3 +181,53 @@ 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;
}
// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10)
public struct ReconStartedEvent
{
public string EventId; // "recon_<номер похода>"
public string TargetId;
public int Level;
public long TimeMs;
}
public struct ReconCompletedEvent
{
public string EventId; // = id отчёта
public string TargetId;
public int Level;
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)
{
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Globalization;
// СИНИЕ КРИСТАЛЛЫ ПРИ ПРЕДЕЛЕ ХРАНИЛИЩА (Регламент редкости синих кристаллов от 05.11.2026, ред. 1.1, раздел 1).
// Документ: Docs/Modules/Economy.md, раздел «Синие кристаллы».
//
// - Начислить сверх предела нельзя, «тихо потерять» излишек -- тоже нельзя.
// - Награда, которая не помещается, выдаётся частично (сколько есть места), остаток ждёт («Можно забрать»)
// и выдаётся позже -- не сгорает. Повторное «Забрать», загрузка, перезапуск не выдают кристалл дважды:
// каждая награда -- по своему id, остаток хранится вместе с id.
// - Одновременно ждут не больше MaxWaiting наград. Пока оба места заняты, новая награда, которая не помещается
// целиком, не выдаётся совсем (задание остаётся «Забрать») -- ничего не теряется.
// Без Unity: те же правила будут на сервере и в тестах. Сколько кристаллов есть и предел -- даёт вызывающий.
public sealed class CrystalRewards
{
public const int MaxWaiting = 2;
readonly Dictionary<string, int> waiting = new Dictionary<string, int>();
public int WaitingCount => waiting.Count;
public IEnumerable<KeyValuePair<string, int>> Waiting => waiting;
// Сколько ещё помещается: целые кристаллы до предела. Предела нет -- сколько угодно
public static int Free(float have, float cap)
{
if (float.IsInfinity(cap) || float.IsNaN(cap)) return int.MaxValue;
return Math.Max(0, (int)Math.Floor(cap - have + 0.0001f));
}
public static int Fits(int amount, float have, float cap) => Math.Max(0, Math.Min(amount, Free(have, cap)));
// Сколько кристаллов этой награды ещё ждут места (0 -- не ждут)
public int Left(string id) => id != null && waiting.TryGetValue(id, out int n) ? n : 0;
// Можно ли начать выдачу новой награды: поместится целиком, уже ждёт или есть свободное место ожидания
public bool CanStart(string id, int amount, float have, float cap)
{
if (amount <= 0 || Fits(amount, have, cap) >= amount) return true;
return Left(id) > 0 || waiting.Count < MaxWaiting;
}
// Выдать награду id: возвращает, сколько кристаллов начислить сейчас; остаток ждёт под этим id.
// Если награда уже ждёт -- выдаётся её остаток (amount не важен: награда одна, повтор её не увеличит).
// Перед новой наградой вызывающий проверяет CanStart
public int Claim(string id, int amount, float have, float cap)
{
if (id == null) id = "";
int need = waiting.TryGetValue(id, out int left) ? left : Math.Max(0, amount);
if (need <= 0) return 0;
int give = Fits(need, have, cap);
if (need - give > 0) waiting[id] = need - give;
else waiting.Remove(id);
return give;
}
// ---------------- Сохранение: строки "id|сколько" ----------------
public List<string> ToLines()
{
var lines = new List<string>();
foreach (var pair in waiting) lines.Add(pair.Key + "|" + pair.Value.ToString(CultureInfo.InvariantCulture));
return lines;
}
public void Load(IEnumerable<string> lines)
{
waiting.Clear();
if (lines == null) return;
foreach (var line in lines)
{
if (string.IsNullOrEmpty(line)) continue;
int bar = line.LastIndexOf('|');
if (bar <= 0) continue;
if (int.TryParse(line.Substring(bar + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) && n > 0)
waiting[line.Substring(0, bar)] = n;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c3e9a41b2d84f6e9a0c5d1e8f2b4a67
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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:

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