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
vologdia 6567b78ecf Экономика, этапы 1 и 3: склады и лимиты, добыча через рабочих 2026-10-27 18:51:35 +03: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
vologdia 256a8fbc3f Экономика: EconomyConfig, модуль Населения, отладочная панель DEV 2026-10-27 18:04:27 +03:00
vologdia 679405b288 EconomyConfig: таблица баланса экономики 2026-10-27 17:59:16 +03:00
195 changed files with 34270 additions and 647 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
@@ -1,6 +1,6 @@
fileFormatVersion: 2
guid: 3a842e2b03903434ba52aa026b801445
TextScriptImporter:
guid: dd067b5f8139dbc46b51ef157b0f0613
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
+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:
+71
View File
@@ -0,0 +1,71 @@
%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: 4ddb59400c6d4b08b3afaaadc461ec1e, type: 3}
m_Name: EconomyConfig
m_EditorClassIdentifier:
population:
startTotal: 300
startWorkers: 120
startSoldiers: 30
foodPerCivilianPerHour: 0.1
foodPerSoldierPerHour: 0.2
foodPerTroopTypePerHour:
- 0.15
- 0.2
- 0.15
- 0.15
growthPerHour: 2.5
housingBase: 300
housingPerTownHallLevel: 50
housingPerHouseLevel: 50
maxOfflineHours: 48
startResources:
wood: 500
food: 500
stone: 200
iron: 100
gold: 100
gems: 0
storage:
baseGoods: 1000
baseFood: 1500
perWarehouseLevel: 1000
perGranaryLevel: 1500
crystalCap: 50
warehouseWood: 200
warehouseStone: 100
granaryWood: 150
granaryStone: 50
production:
woodcutterPerHour: 2
farmerPerHour: 2
stonecutterPerHour: 1.5
minerIronPerHour: 1
minerGoldPerHour: 0.4
workerSlotsT1: 30
workerSlotsPerLevel: 10
efficiencyPerLevel: 0.1
upgrade:
baseWood: 150
baseStone: 100
multiplierPerLevel: 1.6
maxLevel: 10
deposits:
poor: 0.6
normal: 1
rich: 1.5
soldier:
people: 1
food: 15
iron: 5
gold: 5
trainSeconds: 60
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: db192d63d5ff4504a4624613292cf3ab
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+26
View File
@@ -0,0 +1,26 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 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:
@@ -1 +0,0 @@
{"TestSuite":"","Date":0,"Player":{"Development":false,"ScreenWidth":0,"ScreenHeight":0,"ScreenRefreshRate":0,"Fullscreen":false,"Vsync":0,"AntiAliasing":0,"Batchmode":false,"RenderThreadingMode":"MultiThreaded","MtRendering":false,"GraphicsJobs":false,"GpuSkinning":false,"Platform":"","ColorSpace":"","AnisotropicFiltering":"","BlendWeights":"","GraphicsApi":"","ScriptingBackend":"IL2CPP","AndroidTargetSdkVersion":"AndroidApiLevelAuto","AndroidBuildSystem":"Gradle","BuildTarget":"Android","StereoRenderingPath":"MultiPass"},"Hardware":{"OperatingSystem":"","DeviceModel":"","DeviceName":"","ProcessorType":"","ProcessorCount":0,"GraphicsDeviceName":"","SystemMemorySizeMB":0},"Editor":{"Version":"6000.5.0f1","Branch":"6000.5/staging","Changeset":"88b47c5e7076","Date":1780938000},"Dependencies":["com.unity.2d.animation@15.1.0","com.unity.2d.aseprite@5.0.3","com.unity.2d.common@14.0.1","com.unity.2d.psdimporter@14.0.3","com.unity.2d.sprite@1.0.0","com.unity.2d.spriteshape@15.0.3","com.unity.2d.tilemap.extras@8.0.3","com.unity.2d.tilemap@1.0.0","com.unity.2d.tooling@3.0.1","com.unity.burst@1.8.29","com.unity.collab-proxy@2.12.4","com.unity.collections@6.5.0","com.unity.ext.nunit@2.1.0","com.unity.ide.rider@3.0.38","com.unity.ide.visualstudio@2.0.26","com.unity.inputsystem@1.19.0","com.unity.mathematics@1.4.0","com.unity.microsoft.gdk.discovery@1.3.0","com.unity.microsoft.gdk.tools@1.7.0","com.unity.microsoft.gdk@1.7.0","com.unity.modules.accessibility@1.0.0","com.unity.modules.adaptiveperformance@1.0.0","com.unity.modules.ai@1.0.0","com.unity.modules.androidjni@1.0.0","com.unity.modules.animation@1.0.0","com.unity.modules.assetbundle@1.0.0","com.unity.modules.audio@1.0.0","com.unity.modules.cloth@1.0.0","com.unity.modules.director@1.0.0","com.unity.modules.hierarchycore@1.0.0","com.unity.modules.imageconversion@1.0.0","com.unity.modules.imgui@1.0.0","com.unity.modules.jsonserialize@1.0.0","com.unity.modules.particlesystem@1.0.0","com.unity.modules.physics@1.0.0","com.unity.modules.physics2d@1.0.0","com.unity.modules.physicscore2d@1.0.0","com.unity.modules.screencapture@1.0.0","com.unity.modules.subsystems@1.0.0","com.unity.modules.terrain@1.0.0","com.unity.modules.terrainphysics@1.0.0","com.unity.modules.tilemap@1.0.0","com.unity.modules.ui@1.0.0","com.unity.modules.uielements@1.0.0","com.unity.modules.umbra@1.0.0","com.unity.modules.unityanalytics@1.0.0","com.unity.modules.unitywebrequest@1.0.0","com.unity.modules.unitywebrequestassetbundle@1.0.0","com.unity.modules.unitywebrequestaudio@1.0.0","com.unity.modules.unitywebrequesttexture@1.0.0","com.unity.modules.unitywebrequestwww@1.0.0","com.unity.modules.vectorgraphics@1.0.0","com.unity.modules.vehicles@1.0.0","com.unity.modules.video@1.0.0","com.unity.modules.wind@1.0.0","com.unity.modules.xr@1.0.0","com.unity.multiplayer.center@1.0.1","com.unity.nuget.mono-cecil@1.11.6","com.unity.nuget.newtonsoft-json@3.2.2","com.unity.purchasing@5.4.2","com.unity.render-pipelines.core@17.5.0","com.unity.render-pipelines.universal@17.5.0","com.unity.render-pipelines.universal-config@17.5.0","com.unity.searcher@4.9.4","com.unity.services.cloud-build@2.0.8","com.unity.services.core@1.18.0","com.unity.shadergraph@17.5.0","com.unity.test-framework.performance@3.5.0","com.unity.test-framework@1.7.0","com.unity.timeline@1.8.12","com.unity.ugui@2.5.0","com.unity.visualscripting@1.9.11"],"Results":[]}
@@ -1 +0,0 @@
{"MeasurementCount":-1}
@@ -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}
@@ -1,6 +1,6 @@
fileFormatVersion: 2
guid: 2fec63729866dd44483350cddf3c2b84
TextScriptImporter:
guid: c1c34719f0605c247a3575d077f3d656
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
+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:
+777
View File
@@ -0,0 +1,777 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// Окно здания. Открывается тапом по зданию (подписей над зданиями больше нет -- всё здесь).
// У каждого здания своё окно:
// - добывающее (Лесопилка, Ферма, Каменоломня, Шахта, Рудник): рабочие этой профессии и кнопки "−" / "+",
// добыча в час, сколько ресурса на складе и когда склад заполнится, что даст улучшение и его цена;
// - Склад и Амбар: запас / лимит по их ресурсам, что даст улучшение и цена;
// - Дом: сколько жилья даёт, население / предел, улучшение и цена;
// - Ратуша: жильё, резерв для найма, что откроется на следующем уровне, цена улучшения;
// - Кристалл: где добывается, запас / лимит;
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
// кнопка "Нанять войска" (панель "Войска").
// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы.
// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне.
// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11).
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
public static class BuildingWindow
{
static GameObject root;
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();
}
public static void Close()
{
if (root != null) UnityEngine.Object.Destroy(root);
root = null;
focus = null;
live.Clear();
}
static void Rebuild()
{
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)
var rc = focus != null ? focus.GetComponent<ResourceCollector>() : null;
var storage = focus != null ? focus.GetComponent<StorageBuilding>() : null;
var townHall = focus != null ? focus.GetComponent<TownHall>() : null;
var barracks = focus != null ? focus.GetComponent<Barracks>() : null;
var hospital = focus != null ? focus.GetComponent<Hospital>() : null;
var heroCastle = focus != null ? focus.GetComponent<HeroCastle>() : null;
bool constructing = focus != null && BuildTimer.IsConstructing(focus.transform);
if (focus != null) TimerBlock(card, focus);
if (focus == null) WorkersList(card);
else if (constructing) UIKit.Text(card, "Пока здание строится, оно не работает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
else if (townHall != null) TownHallSection(card, townHall);
else if (barracks != null) BarracksSection(card, barracks);
else if (hospital != null) HospitalSection(card, hospital);
else if (heroCastle != null) HeroCastleSection(card);
else if (rc != null && rc.IsWorkplace) WorkplaceSection(card, rc);
else if (rc != null && rc.IsHouse) HouseSection(card, rc);
else if (rc != null && rc.resourceType == ResourceType.Gems) CrystalSection(card);
else if (storage != null) StorageSection(card, storage);
if (focus != null) ActionsBlock(card, focus);
UIKit.Button(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 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();
int level = townHall.currentLevel;
int next = level + 1;
var p = config.population;
UIKit.Text(card, $"Ратуша даёт {p.housingBase + p.housingPerTownHallLevel * (level - 1)} мест жилья. От её уровня зависят резерв для найма и доступные здания и войска.",
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.Civilians}: рабочих {population.Workers}, свободных {population.Idle} • воинов {population.Soldiers}",
22, UIKit.Theme.TextDim);
Live(card, () => $"Резерв для найма: {population.CivilianReserve} • можно нанять: {population.Recruitable}", 22, UIKit.Theme.TextDim);
}
if (level >= townHall.maxLevel)
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
}
// Что даст следующий уровень
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; }
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++)
{
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 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);
return;
}
for (int j = 0; j < WorkJobs.Count; j++)
{
var job = (WorkJob)j;
var row = UIKit.Row(card, 72, 8);
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);
StepButton(row, "−1", job, -1);
StepButton(row, "+1", job, +1);
StepButton(row, "+5", job, +5);
}
Live(card, () => $"Свободных жителей: <b>{pop.FreeWorkers}</b> из {pop.Civilians}", 24, UIKit.Theme.Gold);
}
// Кнопка "−" / "+": активна, только если есть кого снять или куда и кого поставить
static void StepButton(Transform row, string label, WorkJob job, int delta)
{
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);
Tick();
}, 86, 64, 24);
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();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 50195a725abc4fe9b74871cd8cc4b273
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+136
View File
@@ -0,0 +1,136 @@
using System.Collections.Generic;
using System.Text;
using UnityEngine;
// Слой приложения: сообщает модулям, какие здания стоят в городе.
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
// - Дома (здание "Население") -> модуль Населения (предел жилья);
// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection).
// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают.
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
//
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
// (два одинаковых замера подряд) -- чтобы не поймать город посреди восстановления из сохранения.
public class CitySync : MonoBehaviour
{
const float Interval = 0.5f;
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; // последний применённый замер
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (Instance != null) return;
var go = new GameObject("CitySync");
DontDestroyOnLoad(go);
Instance = go.AddComponent<CitySync>();
}
void OnDestroy()
{
if (Instance == this) Instance = null;
}
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + Interval;
if (!CityOpen) return; // город не открыт -- модули помнят последнее
var city = Scan();
if (city.Signature == applied) { pending = null; return; }
if (city.Signature != pending) { pending = city.Signature; return; } // ждём подтверждения
Apply(city);
}
// Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места)
public void SyncNow()
{
if (!CityOpen) return;
Apply(Scan());
}
class City
{
public readonly List<KeyValuePair<WorkJob, int>> Workplaces = new List<KeyValuePair<WorkJob, int>>();
public int HouseLevels, WarehouseLevels, GranaryLevels;
public string Signature;
}
static City Scan()
{
var city = new City();
var sig = new List<string>();
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)
{
if (WorkJobs.TryFromResource(rc.resourceType, out WorkJob job))
{
city.Workplaces.Add(new KeyValuePair<WorkJob, int>(job, rc.level));
sig.Add("w" + (int)job + ":" + rc.level);
}
else if (rc.IsHouse)
{
city.HouseLevels += Mathf.Max(1, rc.level);
sig.Add("h:" + rc.level);
}
}
var sb = placed.GetComponent<StorageBuilding>();
if (sb != null)
{
if (sb.kind == StorageBuilding.Kind.Warehouse) city.WarehouseLevels += Mathf.Max(1, sb.level);
else city.GranaryLevels += Mathf.Max(1, sb.level);
sig.Add("s" + (int)sb.kind + ":" + sb.level);
}
}
sig.Sort(System.StringComparer.Ordinal);
var text = new StringBuilder();
foreach (var s in sig) text.Append(s).Append(';');
city.Signature = text.ToString();
return city;
}
void Apply(City city)
{
applied = city.Signature;
pending = null;
var config = EconomyConfig.Load();
// Лимиты склада
var rm = ResourceManager.Instance;
if (rm != null)
{
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;
// Места для рабочих. Новые места (построили, улучшили) остаются пустыми, пока игрок сам
// не поставит рабочих (регламент 31.10). Мест стало меньше -- лишних рабочих снимаем.
production.SetBuildings(city.Workplaces);
population.SetHouseLevels(city.HouseLevels);
population.ClampJobs();
// Единственное автозаполнение -- стартовая расстановка, один раз: новая игра или первая загрузка старого сохранения
if (!population.JobsInitialized) population.AutoFillAll();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fbcccbb94e6f46c89d4c49bf589b9f19
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+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:
+299
View File
@@ -0,0 +1,299 @@
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using TMPro;
using UnityEngine;
using UnityEngine.UI;
// ОТЛАДОЧНАЯ ПАНЕЛЬ для проверки баланса: кнопка "DEV" слева на экране.
//
// Есть только в редакторе и в Development Build -- в релизную сборку этот файл не попадает совсем.
// "+1 час" не имеет своей логики: он двигает GameClock, и модули досчитывают время по своим обычным правилам,
// ровно как при возвращении игрока в игру. Поэтому проверяешь настоящие правила, а не кнопку.
//
// Перемотка действует на то, что берёт время из GameClock (сейчас -- Население и походы). Обучение
// войск пока идёт по реальным часам.
public class DebugPanel : MonoBehaviour
{
static DebugPanel instance;
GameObject panel;
TextMeshProUGUI info;
UnityEngine.UI.Button gridButton;
float nextRefresh;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (instance != null) return;
var go = new GameObject("DebugPanel", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
DontDestroyOnLoad(go);
var canvas = go.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 1500; // поверх HUD и окон
var scaler = go.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.matchWidthOrHeight = 0.5f;
instance = go.AddComponent<DebugPanel>();
instance.Build();
}
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.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(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);
card.sizeDelta = new Vector2(620, 0);
card.gameObject.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
panel = card.gameObject;
var title = UIKit.Text(card, "ОТЛАДКА БАЛАНСА", 30, UIKit.Theme.Gold, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
info = UIKit.Text(card, "", 24, UIKit.Theme.Text);
var row1 = UIKit.Row(card, 70, 10);
Btn(row1, "+1 час", UIKit.Theme.Blue, () => GameClock.Advance(3600));
Btn(row1, "+10 часов", UIKit.Theme.Blue, () => GameClock.Advance(36000));
var row2 = UIKit.Row(card, 70, 10);
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);
Btn(row3, "Сбросить перемотку", UIKit.Theme.Gray, () => { GameClock.ResetOffset(); Refresh(); });
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);
UIKit.Size(b, -1, 70, 1);
}
void Toggle()
{
panel.SetActive(!panel.activeSelf);
if (panel.activeSelf) Refresh();
}
void Update()
{
if (panel == null || !panel.activeSelf || Time.unscaledTime < nextRefresh) return;
nextRefresh = Time.unscaledTime + 0.5f;
Refresh();
}
void Refresh()
{
if (info == null) return;
var sb = new System.Text.StringBuilder();
if (GameServices.TryGet(out IPopulation p))
{
sb.Append($"Население: <b>{p.Total}</b> / {p.HousingCap}\n");
sb.Append($" жители {p.Civilians}: на работе {p.Workers}, свободно {p.FreeWorkers} из {p.Workforce}; воины {p.Soldiers}\n");
sb.Append($"Еда в час: <color=#ff8a80>-{p.FoodPerHour:0.#}</color> прирост в час: +{p.GrowthPerHour:0.#}\n");
if (p.IsStarving) sb.Append("<color=#ff5252><b>ГОЛОД: еды не хватает, прирост остановлен</b></color>\n");
}
else sb.Append("Модуль Населения не зарегистрирован\n");
if (GameServices.TryGet(out IProduction prod))
sb.Append($"Добыча в час: еда +{prod.RatePerHour(ResourceType.Food):0.#}, дерево +{prod.RatePerHour(ResourceType.Wood):0.#}, " +
$"камень +{prod.RatePerHour(ResourceType.Stone):0.#}, металл +{prod.RatePerHour(ResourceType.Iron):0.#}, золото +{prod.RatePerHour(ResourceType.Gold):0.#}\n");
if (GameServices.TryGet(out IEconomy e))
sb.Append($"Склад: еда {e.Get(ResourceType.Food):0}/{Cap(e, ResourceType.Food)} дерево {e.Get(ResourceType.Wood):0}/{Cap(e, ResourceType.Wood)} " +
$"камень {e.Get(ResourceType.Stone):0}/{Cap(e, ResourceType.Stone)}\n");
else sb.Append("Экономика не найдена (нет ResourceManager на сцене)\n");
long off = GameClock.OffsetSeconds;
sb.Append(off > 0 ? $"Перемотано: +{off / 3600f:0.#} ч" : "Время: реальное");
info.text = sb.ToString();
}
static string Cap(IEconomy e, ResourceType t)
{
float c = e.GetCapacity(t);
return float.IsInfinity(c) ? "∞" : c.ToString("0");
}
void AddAllResources()
{
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
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)
{
if (GameServices.TryGet(out IEconomy e)) e.Add(type, amount);
Refresh();
}
void ZeroFood()
{
if (GameServices.TryGet(out IEconomy e)) e.Add(ResourceType.Food, -e.Get(ResourceType.Food));
Refresh();
}
void OnDestroy()
{
if (instance == this) instance = null;
}
}
#endif
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c2cba3db366a45feaaa3e1f4867ae423
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+329
View File
@@ -0,0 +1,329 @@
using System;
using UnityEngine;
// ТАБЛИЦА БАЛАНСА ЭКОНОМИКИ. Все числа экономики -- здесь, а не в коде модулей.
//
// Ассет: Assets/Resources/EconomyConfig.asset (выдели его в окне Project и меняй числа в Inspector,
// перекомпиляция не нужна). Создать заново: правый клик в Project -> Create -> Bogatyri -> Economy Config,
// имя файла -- EconomyConfig, папка -- Resources.
//
// Модули конфиг не видят: App/GameModules переводит его в правила модулей (например, правила Населения).
// Числа -- из дизайн-документа. Помеченные "не утверждено" -- временные, их решает команда.
[CreateAssetMenu(fileName = "EconomyConfig", menuName = "Bogatyri/Economy Config")]
public class EconomyConfig : ScriptableObject
{
[Serializable]
public class PopulationSettings
{
[Tooltip("Стартовый город T1: всего людей")]
public int startTotal = 300;
[Tooltip("Из них рабочих")]
public int startWorkers = 120;
[Tooltip("Из них воинов (выдаются в Армию пехотой при новой игре)")]
public int startSoldiers = 30;
[Tooltip("Еды в час на одного жителя")]
public float foodPerCivilianPerHour = 0.1f;
[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;
[Tooltip("Предел населения без Домов на 1 уровне Ратуши (решение команды от 19.10: 300 при 300 -- прирост стоит)")]
public int housingBase = 300;
[Tooltip("Прибавка к пределу за каждый следующий уровень Ратуши. Не утверждено")]
public int housingPerTownHallLevel = 50;
[Tooltip("Прибавка к пределу за каждый уровень Дома (здание \"Население\"). Не утверждено")]
public int housingPerHouseLevel = 50;
[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]
public class ProductionSettings // этап 3: производство через рабочих
{
[Tooltip("Лесоруб T1: дерева в час")]
public float woodcutterPerHour = 2f;
[Tooltip("Фермер T1: еды в час")]
public float farmerPerHour = 2f;
[Tooltip("Каменотёс T1: камня в час")]
public float stonecutterPerHour = 1.5f;
[Tooltip("Шахтёр T1: металла в час")]
public float minerIronPerHour = 1f;
[Tooltip("Шахтёр T1: золота в час")]
public float minerGoldPerHour = 0.4f;
[Tooltip("Мест для рабочих в здании T1 (Лесопилка и т.п.)")]
public int workerSlotsT1 = 30;
[Tooltip("+ мест за каждый уровень здания выше первого. Не утверждено")]
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]
public class StorageSettings // этап 1: лимиты хранения
{
[Tooltip("Лимит без Складов: дерево, камень, металл, золото. Не утверждено")]
public float baseGoods = 1000f;
[Tooltip("Лимит еды без Амбаров. Не утверждено")]
public float baseFood = 1500f;
[Tooltip("+ к лимиту дерева, камня, металла, золота за каждый уровень Склада. Не утверждено")]
public float perWarehouseLevel = 1000f;
[Tooltip("+ к лимиту еды за каждый уровень Амбара. Не утверждено")]
public float perGranaryLevel = 1500f;
[Tooltip("Лимит синего кристалла (решение команды от 19.10: небольшой, не бесконечный). Число не утверждено")]
public float crystalCap = 50f;
[Tooltip("Цена Склада: дерево / камень. Не утверждено")]
public float warehouseWood = 200f;
public float warehouseStone = 100f;
[Tooltip("Цена Амбара: дерево / камень. Не утверждено")]
public float granaryWood = 150f;
public float granaryStone = 50f;
}
[Serializable]
public class UpgradeSettings // улучшение зданий (рабочие места, Дома, Склады, Амбары)
{
[Tooltip("Цена улучшения с 1 на 2 уровень: дерево / камень. Не утверждено")]
public float baseWood = 150f;
public float baseStone = 100f;
[Tooltip("Во сколько раз дорожает каждый следующий уровень. Не утверждено")]
public float multiplierPerLevel = 1.6f;
public int maxLevel = 10;
}
[Serializable]
public class DepositSettings // этап 4: качество месторождения (пока не используется)
{
public float poor = 0.6f;
public float normal = 1f;
public float rich = 1.5f;
}
[Serializable]
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]
public class StartResources
{
[Tooltip("Ресурсы новой игры. Не утверждено")]
public float wood = 500f;
public float food = 500f;
public float stone = 200f;
public float iron = 100f;
public float gold = 100f;
public float gems = 0f;
}
[Header("Население (этап 2)")]
public PopulationSettings population = new PopulationSettings();
[Header("Стартовые ресурсы новой игры")]
public StartResources startResources = new StartResources();
[Header("Склады и лимиты (этап 1)")]
public StorageSettings storage = new StorageSettings();
[Header("Производство T1 (этап 3)")]
public ProductionSettings production = new ProductionSettings();
[Header("Улучшение зданий")]
public UpgradeSettings upgrade = new UpgradeSettings();
[Header("Месторождения (этап 4)")]
public DepositSettings deposits = new DepositSettings();
[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;
// Конфиг из Assets/Resources/EconomyConfig.asset. Нет ассета -- числа по умолчанию (из этого файла) и предупреждение.
public static EconomyConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<EconomyConfig>("EconomyConfig");
if (cached == null)
{
Debug.LogWarning("[ЭКОНОМИКА] Нет Assets/Resources/EconomyConfig.asset -- берутся числа по умолчанию из EconomyConfig.cs.");
cached = CreateInstance<EconomyConfig>();
}
return cached;
}
// Лимит склада для ресурса по уровням Складов и Амбаров. PositiveInfinity -- без лимита (люди, сила)
public float GetCapacity(ResourceType type, int warehouseLevels, int granaryLevels)
{
var s = storage;
switch (type)
{
case ResourceType.Wood:
case ResourceType.Stone:
case ResourceType.Iron:
case ResourceType.Gold: return s.baseGoods + s.perWarehouseLevel * warehouseLevels;
case ResourceType.Food: return s.baseFood + s.perGranaryLevel * granaryLevels;
case ResourceType.Gems: return s.crystalCap;
default: return float.PositiveInfinity;
}
}
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -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)
{
float m = Mathf.Pow(upgrade.multiplierPerLevel, Mathf.Max(0, level - 1));
return new[]
{
new ResourceAmount(ResourceType.Wood, Mathf.Round(upgrade.baseWood * m)),
new ResourceAmount(ResourceType.Stone, Mathf.Round(upgrade.baseStone * m)),
};
}
public float GetStartResource(ResourceType type)
{
var s = startResources;
switch (type)
{
case ResourceType.Wood: return s.wood;
case ResourceType.Food: return s.food;
case ResourceType.Stone: return s.stone;
case ResourceType.Iron: return s.iron;
case ResourceType.Gold: return s.gold;
case ResourceType.Gems: return s.gems;
default: return 0f;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4ddb59400c6d4b08b3afaaadc461ec1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+216 -3
View File
@@ -8,19 +8,112 @@ using UnityEngine;
// Модули друг о друге ничего не знают -- всю "проводку" видно в одном файле.
public static class GameModules
{
// Модули экономики для слоя приложения (окна, синхронизация города). Модули друг друга так не берут -- только через контракты.
public static PopulationService Population { get; private set; }
public static ProductionService Production { get; private set; }
// После SubsystemRegistration (там ядро очищается) и раньше любых Awake
[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();
var population = new PopulationService(ToPopulationRules(config));
Population = population;
GameServices.Register<IPopulation>(population);
SaveRegistry.Register(population);
// Добыча через рабочих -- чистый C# (секция "production"). Какие здания стоят, сообщает App/CitySync.
var production = new ProductionService(ToProductionRules(config));
Production = production;
GameServices.Register<IProduction>(production);
SaveRegistry.Register(production);
// Склад и Амбар появляются в списке стройки кодом -- до восстановления зданий из сохранения
EventBus.Subscribe<SettlementRestoringEvent>(_ => StorageBootstrap.Ensure());
// Тап по зданию -> окно здания и рабочих (статические события переживают Play в редакторе -- сначала отписка)
ResourceCollector.Tapped -= BuildingWindow.OnWorkplaceTapped;
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()); // воины -- тоже люди
EventBus.Subscribe<TownHallLevelChangedEvent>(_ => population.Changed()); // предел жилья вырос
EventBus.Subscribe<PopulationChangedEvent>(SyncPopulationResource);
EventBus.Subscribe<NewGameStartedEvent>(_ => GiveStartingKit(population, army, config));
EventBus.Subscribe<GameResetEvent>(_ => GiveStartingKit(population, army, config));
// Круг -- чистый C#, сам сохраняет, в каком Круге игрок (секция "circle")
var circle = new CircleService();
@@ -33,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);
@@ -42,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()
@@ -97,6 +212,104 @@ 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)
{
var p = config.population;
return new PopulationRules
{
StartTotal = p.startTotal,
StartWorkers = p.startWorkers,
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,
CivilianReserveByTownHallLevel = p.civilianReserveByTownHallLevel != null && p.civilianReserveByTownHallLevel.Length > 0
? (int[])p.civilianReserveByTownHallLevel.Clone()
: new PopulationRules().CivilianReserveByTownHallLevel,
PeoplePerSoldier = Mathf.Max(1, config.soldier.people)
};
}
static ProductionRules ToProductionRules(EconomyConfig config)
{
var p = config.production;
return new ProductionRules
{
// порядок как в WorkJob: лесоруб, фермер, каменотёс, шахтёр-металл, шахтёр-золото
PerWorkerPerHour = new double[] { p.woodcutterPerHour, p.farmerPerHour, p.stonecutterPerHour, p.minerIronPerHour, p.minerGoldPerHour },
SlotsPerBuilding = p.workerSlotsT1,
SlotsPerLevel = p.workerSlotsPerLevel,
EfficiencyPerLevel = p.efficiencyPerLevel,
FamineNonFoodMultiplier = p.famineNonFoodMultiplier
};
}
// Новая игра: стартовое войско (пехота) и стартовые ресурсы из EconomyConfig. Один раз на новую игру.
static void GiveStartingKit(PopulationService population, ArmyService army, EconomyConfig config)
{
if (!population.TryTakeStartingKit()) return;
if (population.Rules.StartSoldiers > 0) army.AddTroops(0, population.Rules.StartSoldiers);
if (GameServices.TryGet(out IEconomy economy))
{
ResourceType[] types = { ResourceType.Wood, ResourceType.Food, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Gems };
foreach (var t in types)
{
float target = config.GetStartResource(t);
float current = economy.Get(t);
if (target > current) economy.Add(t, target - current); // не меньше стартового
}
}
Debug.Log($"[НАСЕЛЕНИЕ] Новая игра: {population.Total} человек, воинов {population.Soldiers}.");
}
// Верхняя панель показывает людей как ресурс "Люди" -- держим его равным населению
static void SyncPopulationResource(PopulationChangedEvent e)
{
if (!GameServices.TryGet(out IEconomy economy)) return;
float delta = e.Total - economy.Get(ResourceType.Population);
if (Mathf.Abs(delta) >= 0.5f) economy.Add(ResourceType.Population, delta);
}
static void OnMarchStarted(MarchStartedEvent e)
{
var data = GameProgress.Instance.Data;
+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:
+38
View File
@@ -0,0 +1,38 @@
using UnityEngine;
// Слой приложения: "сердцебиение" модуля Населения. Модуль -- чистый C# и не знает про Update,
// поэтому раз в несколько секунд его досчитывает этот объект. Живёт между сценами (город <-> карта мира).
// Создаётся сам при запуске игры, вешать никуда не нужно.
public class PopulationTicker : MonoBehaviour
{
const float Interval = 2f; // секунд реального времени между расчётами
static PopulationTicker instance;
float next;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (instance != null) return;
var go = new GameObject("PopulationTicker");
DontDestroyOnLoad(go);
instance = go.AddComponent<PopulationTicker>();
}
void Start()
{
next = Time.unscaledTime + Interval; // первый расчёт -- после загрузки сохранения (она идёт в Start сцены)
}
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + Interval;
if (GameServices.TryGet(out IPopulation population)) population.Tick(GameClock.UnixNow);
}
void OnDestroy()
{
if (instance == this) instance = null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d7b4f971ff440eaa5744fbe2a1e834d
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
+85
View File
@@ -0,0 +1,85 @@
using UnityEngine;
// Добавляет "Склад" и "Амбар" в список стройки кодом -- как Казармы (Meta/Build/BarracksBootstrap).
// Шаблон -- копия обычного здания без добычи; пока нет арта, отличаем цветом.
// Вызывается при загрузке сцены и перед восстановлением города из сохранения (SettlementRestoringEvent),
// иначе построенные Склады не нашлись бы при загрузке.
public static class StorageBootstrap
{
public const string WarehouseName = "Склад";
public const string GranaryName = "Амбар";
static GameObject warehouseTemplate;
static GameObject granaryTemplate;
[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;
bool hasWarehouse = false, hasGranary = false;
BuildingData template = null;
foreach (var b in bm.allBuildings)
{
if (b == null) continue;
if (b.buildingName == WarehouseName) hasWarehouse = true;
if (b.buildingName == GranaryName) hasGranary = true;
if (template == null && b.prefab != null && b.prefab.GetComponent<ResourceCollector>() != null) template = b;
}
if (hasWarehouse && hasGranary) return;
if (template == null) { Debug.LogWarning("[СКЛАДЫ] Нет здания-шаблона в BuildManager"); return; }
var cfg = EconomyConfig.Load().storage;
if (!hasWarehouse)
{
if (warehouseTemplate == null) warehouseTemplate = MakeTemplate(template.prefab, "Warehouse", StorageBuilding.Kind.Warehouse, new Color(0.85f, 0.7f, 0.5f));
bm.allBuildings.Add(MakeData(WarehouseName, template, warehouseTemplate, cfg.warehouseWood, cfg.warehouseStone));
}
if (!hasGranary)
{
if (granaryTemplate == null) granaryTemplate = MakeTemplate(template.prefab, "Granary", StorageBuilding.Kind.Granary, new Color(1f, 0.92f, 0.55f));
bm.allBuildings.Add(MakeData(GranaryName, template, granaryTemplate, cfg.granaryWood, cfg.granaryStone));
}
Debug.Log("[СКЛАДЫ] Склад и Амбар добавлены в список стройки");
}
static GameObject MakeTemplate(GameObject source, string name, StorageBuilding.Kind kind, Color tint)
{
var t = Object.Instantiate(source);
t.name = name;
t.SetActive(false);
Object.DontDestroyOnLoad(t);
foreach (var c in t.GetComponents<ResourceCollector>()) Object.DestroyImmediate(c);
foreach (var c in t.GetComponents<ResourceProducer>()) Object.DestroyImmediate(c);
foreach (var c in t.GetComponents<BuildingUI>()) Object.DestroyImmediate(c);
for (int i = t.transform.childCount - 1; i >= 0; i--)
Object.DestroyImmediate(t.transform.GetChild(i).gameObject); // полоска над зданием не нужна
var sr = t.GetComponent<SpriteRenderer>();
if (sr != null) sr.color = tint;
t.AddComponent<StorageBuilding>().kind = kind;
return t;
}
static BuildingData MakeData(string title, BuildingData template, GameObject prefab, float wood, float stone)
{
var data = ScriptableObject.CreateInstance<BuildingData>();
data.name = "Data_" + prefab.name + "_Runtime";
data.buildingName = title;
data.icon = template.icon;
data.prefab = prefab;
data.requiredTownHallLevel = 1;
data.cost = new[]
{
new ResourceCost { type = ResourceType.Wood, amount = wood },
new ResourceCost { type = ResourceType.Stone, amount = stone },
};
return data;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 87b085cfbef5449b97456bea718c7466
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+35
View File
@@ -0,0 +1,35 @@
using System;
using UnityEngine;
// Склад или Амбар (этап 1): каждый уровень поднимает лимит хранения.
// Склад -- дерево, камень, металл, золото; Амбар -- еда. Числа -- EconomyConfig, раздел "Склады и лимиты".
// Здание появляется в списке стройки кодом (App/StorageBootstrap), отдельных префабов пока нет.
// Уровень сохраняет модуль Поселения (ILeveledBuilding), лимиты считает App/CitySync.
public class StorageBuilding : MonoBehaviour, ILeveledBuilding
{
public enum Kind { Warehouse, Granary }
public static event Action<StorageBuilding> Tapped;
public Kind kind;
public int level = 1;
public int Level { get => level; set => level = Mathf.Max(1, value); }
public string Title => kind == Kind.Warehouse ? StorageBootstrap.WarehouseName : StorageBootstrap.GranaryName;
Vector3 downPos;
void OnMouseDown()
{
downPos = Input.mousePosition;
}
// Тап, а не перетаскивание камеры
void OnMouseUpAsButton()
{
if (UIBlocker.IsPointerOverUI()) return;
if ((Input.mousePosition - downPos).magnitude > 25f) return;
Tapped?.Invoke(this);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bea97fb12da84c7daeb07f65d4947cae
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+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
+38 -8
View File
@@ -2,6 +2,8 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
// Подпись над зданием -- больше не показывается: всё о здании теперь в его окне (App/BuildingWindow, тап по зданию).
// Компонент оставлен, чтобы не перенастраивать префабы: при запуске он прячет свою подпись и полоску и выключается.
public class BuildingUI : MonoBehaviour
{
[Header("Настройки")]
@@ -9,8 +11,18 @@ public class BuildingUI : MonoBehaviour
public TMP_Text stokText;
public Slider progressBar;
float next;
public static bool ShowLabels = false; // вернуть подписи над зданиями -- поставить true
void Start()
{
if (!ShowLabels)
{
HideLabel();
enabled = false;
return;
}
if (building == null)
{
Debug.LogError("Ошибка BuildingUI: не назначено здание (Building)!");
@@ -20,21 +32,39 @@ 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;
next = Time.unscaledTime + 0.5f;
UpdateUI();
}
void UpdateUI()
{
if (stokText != null)
bool workplace = building.IsWorkplace;
if (progressBar != null && progressBar.gameObject.activeSelf != workplace) progressBar.gameObject.SetActive(workplace);
if (!workplace)
{
stokText.text = $"{building.GetCurrentStock():F1} / {building.GetMaxCapacity()}";
}
if (progressBar != null)
{
float fill = building.GetCurrentStock() / building.GetMaxCapacity();
progressBar.value = Mathf.Clamp01(fill);
if (stokText != null) stokText.text = building.IsHouse ? $"Дом {building.level} ур." : $"{building.level} ур.";
return;
}
float workers = building.GetCurrentStock();
float slots = building.GetMaxCapacity();
if (stokText != null) stokText.text = $"Рабочие {workers:0} / {slots:0}";
if (progressBar != null) progressBar.value = slots > 0 ? Mathf.Clamp01(workers / slots) : 0f;
}
}
}
+67 -46
View File
@@ -1,71 +1,92 @@
using UnityEngine;
using TMPro;
// Ресурс на верхней панели (этап 1): "запас / лимит" и прирост в час.
// - Дерево, камень, металл, золото, еда, кристаллы: "820 / 1000" и строкой ниже "+50/ч" (у еды -- добыча минус расход);
// - Люди: "300 / 350" (население / предел жилья) и прирост людей в час.
// Числа без сокращений 1.2K / 1.5M: по документу нормальный масштаб -- сотни и тысячи.
[RequireComponent(typeof(TMP_Text))]
public class ResourceUIText : MonoBehaviour
{
public ResourceType resourceType;
[Tooltip("Сокращать большие числа (1200 -> 1.2K, 1500000 -> 1.5M), как на референсе HUD")]
public bool useShortFormat = true;
[Tooltip("Показывать лимит склада: \"820 / 1000\"")]
public bool showCapacity = true;
[Tooltip("Показывать прирост в час второй строкой: \"+50/ч\"")]
public bool showRate = true;
[Tooltip("Устарело (этап 1): сокращения 1.2K / 1.5M убраны")]
[HideInInspector] public bool useShortFormat;
private TMP_Text label;
private float next;
private string lastText;
void Awake()
{
label = GetComponent<TMP_Text>();
// В сцене на ячейках дерева, камня, металла, золота и кристалла по ошибке висит второй такой же компонент
// с типом "Люди" -- он перебивал число ресурса. Если рядом есть компонент с настоящим ресурсом -- выключаемся.
if (resourceType == ResourceType.Population)
{
foreach (var other in GetComponents<ResourceUIText>())
{
if (other != this && other.resourceType != ResourceType.Population)
{
enabled = false;
return;
}
}
}
}
void OnEnable()
{
// Ждём, пока ResourceManager точно проинициализируется (Awake порядок не гарантирован)
if (ResourceManager.Instance != null)
next = 0f;
}
void Update()
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
Refresh();
}
void Refresh()
{
string text = BuildText();
if (text == lastText) return;
lastText = text;
label.text = text;
}
string BuildText()
{
// Люди -- из модуля Населения
if (resourceType == ResourceType.Population && GameServices.TryGet(out IPopulation pop))
{
Subscribe();
}
else
{
Invoke(nameof(TrySubscribeDelayed), 0.1f);
string value = showCapacity ? $"{pop.Total} / {pop.HousingCap}" : pop.Total.ToString();
return showRate ? value + RateLine(pop.GrowthPerHour) : value;
}
if (!GameServices.TryGet(out IEconomy economy)) return "0";
int amount = Mathf.FloorToInt(economy.Get(resourceType));
float cap = economy.GetCapacity(resourceType);
string main = showCapacity && !float.IsInfinity(cap) ? $"{amount} / {Mathf.RoundToInt(cap)}" : amount.ToString();
if (!showRate || resourceType == ResourceType.Power) return main;
float rate = GameServices.TryGet(out IProduction production) ? production.RatePerHour(resourceType) : 0f;
if (resourceType == ResourceType.Food && GameServices.TryGet(out IPopulation p)) rate -= p.FoodPerHour;
if (resourceType == ResourceType.Gems) return main; // кристалл не добывается рабочими (разломы на карте мира)
return main + RateLine(rate);
}
void TrySubscribeDelayed()
static string RateLine(float rate)
{
if (ResourceManager.Instance != null)
Subscribe();
}
void Subscribe()
{
ResourceManager.Instance.OnResourceChanged += HandleResourceChanged;
// сразу показать текущее значение при старте, не дожидаясь первого изменения
UpdateText(ResourceManager.Instance.GetResource(resourceType));
}
void OnDisable()
{
if (ResourceManager.Instance != null)
ResourceManager.Instance.OnResourceChanged -= HandleResourceChanged;
}
void HandleResourceChanged(ResourceType type, float newValue)
{
if (type != resourceType) return; // реагируем только на "свой" ресурс
UpdateText(newValue);
}
void UpdateText(float value)
{
label.text = useShortFormat ? FormatShort(value) : Mathf.FloorToInt(value).ToString();
}
string FormatShort(float value)
{
if (value >= 1_000_000f)
return (value / 1_000_000f).ToString("0.#") + "M";
if (value >= 1_000f)
return (value / 1_000f).ToString("0.#") + "K";
return Mathf.FloorToInt(value).ToString();
string color = rate < 0 ? "#ff8a80" : "#9be29b";
string sign = rate > 0 ? "+" : "";
return $"\n<size=60%><color={color}>{sign}{rate:0.#}/ч</color></size>";
}
}
+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

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