52 Commits
Author SHA1 Message Date
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
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
144 changed files with 27645 additions and 463 deletions
+4
View File
@@ -72,3 +72,7 @@ crashlytics-build.properties
/[Aa]ssets/[Ss]treamingAssets/aa.meta
/[Aa]ssets/[Ss]treamingAssets/aa/*
# Сборки APK (Tools -> Bogatyri -> Собрать APK) и служебные папки сборки
/Builds/
/build/
/MVP1_BurstDebugInformation_DoNotShip/
+61
View File
@@ -0,0 +1,61 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.Android;
using UnityEngine;
// Починка сборки Android на этом компьютере (05.11):
// 1. Имя пользователя Windows с русскими буквами (C:\Users\Вальдемар). Gradle хранит свои файлы в
// C:\Users\<имя>\.gradle, и одна из его программ (prefab) не понимает такой путь -- сборка падает.
// Перед сборкой переносим папку Gradle в C:\gradle (переменная GRADLE_USER_HOME для процесса Unity,
// сборщик её наследует). Ничего в Windows менять не нужно.
// 2. Два Kotlin в зависимостях (1.8 у Unity и 1.6 у одного из пакетов) -- "Duplicate class kotlin...".
// В сгенерированный проект Gradle дописываем правило: все kotlin-stdlib -- одной версии 1.8.22.
[InitializeOnLoad]
public class AndroidBuildFixes : IPostGenerateGradleAndroidProject
{
const string GradleHome = @"C:\gradle";
const string Marker = "// Bogatyri: kotlin-stdlib одной версии";
static AndroidBuildFixes() { EnsureAsciiGradleHome(); }
public static void EnsureAsciiGradleHome()
{
string current = Environment.GetEnvironmentVariable("GRADLE_USER_HOME");
if (!string.IsNullOrEmpty(current) && IsAscii(current)) return;
if (IsAscii(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))) return; // путь и так латиницей
try
{
Directory.CreateDirectory(GradleHome);
Environment.SetEnvironmentVariable("GRADLE_USER_HOME", GradleHome, EnvironmentVariableTarget.Process);
Debug.Log("[СБОРКА] Папка Gradle: " + GradleHome + " (в пути пользователя есть русские буквы)");
}
catch (Exception e) { Debug.LogWarning("[СБОРКА] Не удалось задать GRADLE_USER_HOME: " + e.Message); }
}
static bool IsAscii(string s) => !Regex.IsMatch(s ?? "", @"[^\u0000-\u007F]");
public int callbackOrder => 999;
// path -- папка unityLibrary сгенерированного проекта
public void OnPostGenerateGradleAndroidProject(string path)
{
string root = Directory.GetParent(path).FullName;
foreach (var file in new[] { Path.Combine(root, "launcher", "build.gradle"), Path.Combine(path, "build.gradle") })
{
if (!File.Exists(file)) continue;
string text = File.ReadAllText(file);
if (text.Contains(Marker)) continue;
text += "\n" + Marker + "\n" +
"configurations.all {\n" +
" resolutionStrategy.eachDependency { details ->\n" +
" if (details.requested.group == 'org.jetbrains.kotlin' && details.requested.name.startsWith('kotlin-stdlib')) {\n" +
" details.useVersion('1.8.22')\n" +
" }\n" +
" }\n" +
"}\n";
File.WriteAllText(file, text);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 030e644cb052c0f4d959e499015370fd
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.Build.Reporting;
// Сборка APK одной кнопкой (05.11): Tools -> Bogatyri -> Собрать APK.
// Файл: C:\MVP1\Builds\Bogatyri_<ветка>_v<версия>_<дата>.apk; сцены -- из списка сборки (первая -- стартовая).
// Итог сборки (успех, размер, первые ошибки) пишется в Builds\last_build.txt.
// Сборку можно запустить и файлом-запросом Builds\build_request.txt (так её запускает Claude): Unity заметит
// его в течение пары секунд, удалит и начнёт сборку.
[InitializeOnLoad]
public static class ApkBuilder
{
const string Folder = "Builds";
const string RequestFile = "Builds/build_request.txt";
const string ResultFile = "Builds/last_build.txt";
static double nextCheck;
static ApkBuilder()
{
EditorApplication.update += Poll;
}
static void Poll()
{
if (EditorApplication.timeSinceStartup < nextCheck) return;
nextCheck = EditorApplication.timeSinceStartup + 2;
if (!File.Exists(RequestFile) || BuildPipeline.isBuildingPlayer || EditorApplication.isCompiling || EditorApplication.isPlaying) return;
File.Delete(RequestFile);
EditorApplication.delayCall += Build;
}
[MenuItem("Tools/Bogatyri/Собрать APK")]
public static void Build()
{
AndroidBuildFixes.EnsureAsciiGradleHome();
Directory.CreateDirectory(Folder);
string branch = Git("rev-parse --abbrev-ref HEAD").Replace('/', '-');
if (branch.Length == 0) branch = "local";
string file = $"{Folder}/Bogatyri_{branch}_v{PlayerSettings.bundleVersion}_{DateTime.Now:yyyyMMdd_HHmm}.apk";
var scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
File.WriteAllText(ResultFile, $"Сборка идёт: {DateTime.Now:dd.MM.yyyy HH:mm}\n{file}\n");
BuildReport report;
try
{
EditorUserBuildSettings.buildAppBundle = false; // обычный APK
report = BuildPipeline.BuildPlayer(new BuildPlayerOptions
{
scenes = scenes,
locationPathName = file,
target = BuildTarget.Android,
options = BuildOptions.None
});
}
catch (Exception e)
{
File.WriteAllText(ResultFile, $"ОШИБКА {DateTime.Now:dd.MM.yyyy HH:mm}\n{e}\n");
throw;
}
var s = report.summary;
var text = new StringBuilder();
text.AppendLine($"{(s.result == BuildResult.Succeeded ? "ГОТОВО" : "ОШИБКА")} {DateTime.Now:dd.MM.yyyy HH:mm} результат: {s.result}");
text.AppendLine($"Файл: {Path.GetFullPath(file)}");
if (s.result == BuildResult.Succeeded && File.Exists(file)) text.AppendLine($"Размер: {new FileInfo(file).Length / 1048576.0:0.0} МБ");
text.AppendLine($"Версия: {PlayerSettings.bundleVersion}, код {PlayerSettings.Android.bundleVersionCode}, ветка {branch} {Git("rev-parse --short HEAD")}");
text.AppendLine($"Сцены: {string.Join(", ", scenes)}");
text.AppendLine($"Время: {s.totalTime}, ошибок: {s.totalErrors}, предупреждений: {s.totalWarnings}");
int shown = 0;
foreach (var step in report.steps)
foreach (var m in step.messages)
if (m.type == UnityEngine.LogType.Error || m.type == UnityEngine.LogType.Exception)
{
if (shown++ >= 15) break;
string msg = m.content.Length > 1500 ? m.content.Substring(0, 1500) + " ..." : m.content;
text.AppendLine("--- " + msg);
}
File.WriteAllText(ResultFile, text.ToString());
UnityEngine.Debug.Log("[СБОРКА] " + text.ToString().Split('\n')[0] + " -> " + file);
if (s.result == BuildResult.Succeeded) EditorUtility.RevealInFinder(file);
}
static string Git(string args)
{
try
{
var p = Process.Start(new ProcessStartInfo("git", args)
{
RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true,
WorkingDirectory = Directory.GetCurrentDirectory()
});
string o = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit(5000);
return o;
}
catch { return ""; }
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 904233d7647ed024d920bdbbd4058aba
+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Diagnostics;
using System.IO;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
// Перед каждым билдом записывает в Assets/Resources/BuildInfo.txt: ветку, коммит и дату сборки (05.11).
// Игра показывает это в углу экрана рядом с версией (App/GameVersionLabel). После билда файл удаляется,
// чтобы не попадать в git.
public class BuildInfoWriter : IPreprocessBuildWithReport, IPostprocessBuildWithReport
{
const string Path = "Assets/Resources/BuildInfo.txt";
public int callbackOrder => 0;
public void OnPreprocessBuild(BuildReport report)
{
string branch = Or(Git("rev-parse --abbrev-ref HEAD")), commit = Or(Git("rev-parse --short HEAD"));
bool dirty = Git("status --porcelain --untracked-files=no").Length > 0;
string info = $"{branch} {commit}{(dirty ? "+" : "")} • {DateTime.Now:dd.MM.yyyy HH:mm}";
Directory.CreateDirectory("Assets/Resources");
File.WriteAllText(Path, info);
AssetDatabase.ImportAsset(Path);
UnityEngine.Debug.Log("[ВЕРСИЯ] Сборка: v" + PlayerSettings.bundleVersion + " • " + info);
}
public void OnPostprocessBuild(BuildReport report)
{
AssetDatabase.DeleteAsset(Path);
}
static string Git(string args)
{
try
{
var p = Process.Start(new ProcessStartInfo("git", args)
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Directory.GetCurrentDirectory()
});
string output = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit(5000);
return output;
}
catch { return ""; }
}
static string Or(string s) => string.IsNullOrEmpty(s) ? "?" : s;
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1218c67fef1faaf448c6623df897ac12
+119
View File
@@ -0,0 +1,119 @@
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
// Создаёт сцену 3D-поселения (прототип, этап I ТЗ «Система строительства поселения» от 04.11.2026):
// Tools -> Bogatyri -> 3D-город: создать сцену City3D.
//
// Что делает:
// 1. Добавляет в настройки URP второй рендерер -- обычный 3D (Assets/Settings/Renderer3D.asset).
// Старые 2D-сцены продолжают рисоваться 2D-рендерером: он остаётся рендерером по умолчанию.
// 2. Создаёт Assets/Scenes/City3D.unity: камера (3D-рендерер, перспектива), солнце с тенями,
// объект City3D с City3DController -- всё остальное (земля, деревья, здания) он строит сам при запуске.
// 3. Добавляет сцену в Build Settings и открывает её. Дальше -- просто Play.
public static class City3DSetup
{
const string ScenePath = "Assets/Scenes/City3D.unity";
const string RendererPath = "Assets/Settings/Renderer3D.asset";
[MenuItem("Tools/Bogatyri/3D-город: создать сцену City3D")]
static void Create()
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
int rendererIndex = EnsureRenderer3D();
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var camGo = new GameObject("Main Camera") { tag = "MainCamera" };
var cam = camGo.AddComponent<Camera>();
cam.orthographic = false;
cam.fieldOfView = 40f;
cam.nearClipPlane = 0.3f;
cam.farClipPlane = 300f;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = new Color(0.55f, 0.72f, 0.9f);
camGo.AddComponent<AudioListener>();
var data = camGo.AddComponent<UniversalAdditionalCameraData>();
if (rendererIndex >= 0) data.SetRenderer(rendererIndex);
camGo.AddComponent<City3DCamera>();
var sunGo = new GameObject("Sun");
var sun = sunGo.AddComponent<Light>();
sun.type = LightType.Directional;
sun.intensity = 1.2f;
sun.shadows = LightShadows.Soft;
sunGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
RenderSettings.ambientMode = AmbientMode.Flat;
RenderSettings.ambientLight = new Color(0.55f, 0.58f, 0.62f);
new GameObject("City3D").AddComponent<City3DController>();
EditorSceneManager.SaveScene(scene, ScenePath);
AddToBuildSettings();
Debug.Log($"[3D-ГОРОД] Сцена создана: {ScenePath}. 3D-рендерер: индекс {rendererIndex}. Нажмите Play.");
}
// Второй рендерер URP -- обычный (3D). Добавляется в URP Asset из Graphics Settings и из КАЖДОГО уровня качества
// (в этом проекте URP задан только в Quality). Возвращает индекс рендерера в списке (или -1)
[MenuItem("Tools/Bogatyri/3D: добавить 3D-рендерер в URP")]
static void AddRendererMenu()
{
int index = EnsureRenderer3D();
Debug.Log(index >= 0 ? $"[3D] 3D-рендерер подключён (индекс {index}). Можно запускать карту мира и City3D."
: "[3D] URP Asset не найден ни в Graphics, ни в Quality Settings.");
}
static int EnsureRenderer3D()
{
var pipelines = new System.Collections.Generic.List<UniversalRenderPipelineAsset>();
void Add(RenderPipelineAsset a) { if (a is UniversalRenderPipelineAsset u && !pipelines.Contains(u)) pipelines.Add(u); }
Add(GraphicsSettings.defaultRenderPipeline);
for (int q = 0; q < QualitySettings.names.Length; q++) Add(QualitySettings.GetRenderPipelineAssetAt(q));
if (pipelines.Count == 0)
{
Debug.LogWarning("[3D-ГОРОД] Нет URP Asset ни в Graphics, ни в Quality Settings -- камера будет рисовать рендерером по умолчанию");
return -1;
}
var renderer = AssetDatabase.LoadAssetAtPath<UniversalRendererData>(RendererPath);
if (renderer == null)
{
renderer = ScriptableObject.CreateInstance<UniversalRendererData>();
AssetDatabase.CreateAsset(renderer, RendererPath);
AssetDatabase.SaveAssets();
}
int result = -1;
foreach (var pipeline in pipelines)
{
var so = new SerializedObject(pipeline);
var list = so.FindProperty("m_RendererDataList");
int found = -1;
for (int i = 0; i < list.arraySize; i++)
if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) found = i;
if (found < 0)
{
list.arraySize++;
list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = renderer;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(pipeline);
found = list.arraySize - 1;
}
if (result < 0) result = found;
}
AssetDatabase.SaveAssets();
return result;
}
static void AddToBuildSettings()
{
var scenes = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
foreach (var s in scenes) if (s.path == ScenePath) return;
scenes.Add(new EditorBuildSettingsScene(ScenePath, true));
EditorBuildSettings.scenes = scenes.ToArray();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 37d458150cc84d538a23b4c6612995c7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+6 -3
View File
@@ -3,11 +3,13 @@ using UnityEditor;
// Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings --
// иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции.
// Стартовая сцена (05.11): 3D-поселение City3D, если оно есть в проекте, иначе 2D-город MVP1.
[InitializeOnLoad]
public static class SceneListFixer
{
const string CityScene = "Assets/Scenes/MVP1.unity";
const string WorldScene = "Assets/Scenes/WorldMap.unity";
const string City3DScene = "Assets/Scenes/City3D.unity";
static SceneListFixer() { EditorApplication.delayCall += Ensure; }
@@ -15,15 +17,16 @@ public static class SceneListFixer
{
var scenes = new List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
bool changed = false;
foreach (var path in new[] { CityScene, WorldScene })
string start = AssetDatabase.LoadAssetAtPath<SceneAsset>(City3DScene) != null ? City3DScene : CityScene;
foreach (var path in new[] { start, CityScene, WorldScene })
{
if (AssetDatabase.LoadAssetAtPath<SceneAsset>(path) == null) continue;
if (scenes.Exists(s => s.path == path)) continue;
scenes.Add(new EditorBuildSettingsScene(path, true));
changed = true;
}
// город должен быть первым (с него стартует игра)
int city = scenes.FindIndex(s => s.path == CityScene);
// стартовый город должен быть первым (с него стартует игра)
int city = scenes.FindIndex(s => s.path == start);
if (city > 0) { var c = scenes[city]; scenes.RemoveAt(city); scenes.Insert(0, c); changed = true; }
if (changed)
{
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dd067b5f8139dbc46b51ef157b0f0613
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+31 -2
View File
@@ -12,12 +12,41 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3}
m_Name: BattleConfig
m_EditorClassIdentifier:
version: 2
version: 8
counterPercent: 25
wallsPercent: 20
siegeVsWallsPercent: 50
siegeInFieldPercent: -25
loserLossPercent: 50
randomPercent: 5
famineStrengthPercent: 50
fatigueStrengthPercent: 80
lossRatioPermille:
- 1000
- 3000
- 5000
- 7000
- 10000
strongLossPermille:
- 450
- 160
- 110
- 74
- 20
weakLossPermille:
- 450
- 740
- 880
- 920
- 950
weakLossMaxPermille: 990
deadRatioPermille:
- 200
- 500
- 1000
- 3000
deadSharePermille:
- 700
- 450
- 300
- 200
missingSharePermille: 0
+5
View File
@@ -18,6 +18,11 @@ MonoBehaviour:
startSoldiers: 30
foodPerCivilianPerHour: 0.1
foodPerSoldierPerHour: 0.2
foodPerTroopTypePerHour:
- 0.15
- 0.2
- 0.15
- 0.15
growthPerHour: 2.5
housingBase: 300
housingPerTownHallLevel: 50
+26
View File
@@ -0,0 +1,26 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c25bbe993b314a15a6d1a8aaf0017d55, type: 3}
m_Name: HospitalConfig
m_EditorClassIdentifier:
bedsByLevel: 140000001e00000028000000320000003c000000
temporaryBeds: 5
requiredTownHallLevel: 1
buildWood: 250
buildStone: 150
healGameHours: 04000000040000000600000008000000
healFoodPerSoldier: 00000000000000000000000000000000
healGoldPerSoldier: 01000000010000000200000003000000
missingReturnPercent: 50
missingWoundedPercent: 30
missingWindowGameHours: 6
missingMaxGroups: 4
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d92d3f1bd32b432e9a30ee269f436ca0
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+23
View File
@@ -0,0 +1,23 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c249ad34e2f94460b9f99b49c71760c8, type: 3}
m_Name: IntelligenceConfig
m_EditorClassIdentifier:
settlementRadius: 5
armyRadius: 1
observationPercent: 0a000000140000001e000000
testObservationLevel: 0
lastContactMinutes: 10
approxPercent: 20
identifyPercent: 50
sampleStepSeconds: 10
hideUnexploredObjects: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4972288700954eda8b97a86b3f7a5492
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 18e162eeff424f8bb3a71543eafba0c8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+137
View File
@@ -0,0 +1,137 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-7493335797097805923
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Map3DLit
m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 1
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap:
RenderType: Opaque
disabledShaderPasses:
- MOTIONVECTORS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _Metallic: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0.12
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 1, g: 1, b: 1, a: 1}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0ec4d2749e73404eba2facea709d9f24
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
+26
View File
@@ -0,0 +1,26 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0427eef4612041d2b8a359f9bf358779, type: 3}
m_Name: MarchConfig
m_EditorClassIdentifier:
infantry: 8
archers: 9
cavalry: 12
siege: 4
typeSpeedBonusPercent: 00000000000000000000000000000000
armySpeedBonusPercent: 0
baseMarchSlots: 2
patrolsEnabled: 1
patrolMaxActive: 3
patrolIntervalMinutes: 10
patrolSoldiersMin: 10
patrolSoldiersMax: 40
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 99295aa9eabc4ab6bf8cc687f4d89d96
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,58 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6ee3759b377140c0b759a4a386327510, type: 3}
m_Name: WorldResourceConfig
m_EditorClassIdentifier:
woodStock:
- 200
- 600
- 1500
stoneStock:
- 150
- 450
- 1000
metalStock:
- 80
- 250
- 600
goldStock:
- 30
- 100
- 250
woodPerGameHour: 2
stonePerGameHour: 1.5
metalPerGameHour: 1
goldPerGameHour: 0.5
woodCargo: 12
stoneCargo: 10
metalCargo: 8
goldCargo: 5
lifetimeRealHours:
- 12
- 24
- 48
woodCount:
- 4
- 3
- 1
stoneCount:
- 4
- 2
- 1
metalCount:
- 2
- 2
- 1
goldCount:
- 2
- 1
- 1
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b95775cbe52447a6abff99bfeb488500
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+799
View File
@@ -0,0 +1,799 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.55, g: 0.58, b: 0.62, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 3
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 0
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 2
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 1
m_PVRFilteringGaussRadiusAO: 1
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &110794169
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 110794174}
- component: {fileID: 110794173}
- component: {fileID: 110794172}
- component: {fileID: 110794171}
- component: {fileID: 110794170}
- component: {fileID: 110794175}
m_Layer: 0
m_Name: Main Camera
m_TagString: MainCamera
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &110794170
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e8645f0028574904969b5802443f100f, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::City3DCamera
pitch: 52
yaw: 45
distance: 26
minDistance: 8
maxDistance: 55
target: {x: 0, y: 0, z: 0}
bounds:
serializedVersion: 2
x: 0
y: 0
width: 48
height: 48
--- !u!114 &110794171
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData
m_RenderShadows: 1
m_RequiresDepthTextureOption: 2
m_RequiresOpaqueTextureOption: 2
m_CameraType: 0
m_Cameras: []
m_RendererIndex: -1
m_VolumeLayerMask:
serializedVersion: 2
m_Bits: 1
m_VolumeTrigger: {fileID: 0}
m_VolumeFrameworkUpdateModeOption: 2
m_RenderPostProcessing: 0
m_Antialiasing: 0
m_AntialiasingQuality: 2
m_StopNaN: 0
m_Dithering: 0
m_ClearDepth: 1
m_AllowXRRendering: 1
m_AllowHDROutput: 1
m_UseScreenCoordOverride: 0
m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0}
m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0}
m_RequiresDepthTexture: 0
m_RequiresColorTexture: 0
m_TaaSettings:
m_Quality: 3
m_FrameInfluence: 0.1
m_JitterScale: 1
m_MipBias: 0
m_VarianceClampScale: 0.9
m_ContrastAdaptiveSharpening: 0
m_Version: 2
--- !u!81 &110794172
AudioListener:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
--- !u!20 &110794173
Camera:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
serializedVersion: 2
m_ClearFlags: 2
m_BackGroundColor: {r: 0.55, g: 0.72, b: 0.9, a: 1}
m_projectionMatrixMode: 1
m_GateFitMode: 2
m_FOVAxisMode: 0
m_Iso: 200
m_ShutterSpeed: 0.005
m_Aperture: 16
m_FocusDistance: 10
m_FocalLength: 50
m_BladeCount: 5
m_Curvature: {x: 2, y: 11}
m_BarrelClipping: 0.25
m_Anamorphism: 0
m_SensorSize: {x: 36, y: 24}
m_LensShift: {x: 0, y: 0}
m_NormalizedViewPortRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
near clip plane: 0.3
far clip plane: 300
field of view: 40
orthographic: 1
orthographic size: 5
m_Depth: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingPath: -1
m_TargetTexture: {fileID: 0}
m_TargetDisplay: 0
m_TargetEye: 3
m_HDR: 1
m_AllowMSAA: 1
m_AllowDynamicResolution: 0
m_ForceIntoRT: 0
m_OcclusionCulling: 1
m_StereoConvergence: 10
m_StereoSeparation: 0.022
--- !u!4 &110794174
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &110794175
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 110794169}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 772c25e1ad11ec84191ddd62a2559d77, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::CameraController
moveSpeed: 5
zoomSpeed: 2
minOrthoSize: 2
maxOrthoSize: 50
--- !u!1 &429953705
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 429953708}
- component: {fileID: 429953707}
- component: {fileID: 429953706}
m_Layer: 0
m_Name: EventSystem
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &429953706
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 429953705}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4f231c4fb786f3946a6b90b886c48677, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.StandaloneInputModule
m_SendPointerHoverToParent: 1
m_HorizontalAxis: Horizontal
m_VerticalAxis: Vertical
m_SubmitButton: Submit
m_CancelButton: Cancel
m_InputActionsPerSecond: 10
m_RepeatDelay: 0.5
m_ForceModuleActive: 0
--- !u!114 &429953707
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 429953705}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 76c392e42b5098c458856cdf6ecaaaa1, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.EventSystem
m_FirstSelected: {fileID: 0}
m_sendNavigationEvents: 1
m_DragThreshold: 10
--- !u!4 &429953708
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 429953705}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1001 &695231209
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
serializedVersion: 3
m_TransformParent: {fileID: 0}
m_Modifications:
- target: {fileID: 390569600188462384, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: -23.000854
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 628544459168404126, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Pivot.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Pivot.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalPosition.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.w
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalRotation.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalEulerAnglesHint.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2090086157864901368, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3476476116216033849, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3561707029477629692, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 4178416176766117917, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 4178416176766117917, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_SizeDelta.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 5046265208400373706, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_sharedMaterial
value:
objectReference: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
- target: {fileID: 5449404431136268404, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Camera
value:
objectReference: {fileID: 110794173}
- target: {fileID: 5795784041573784673, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_Name
value: HUD_Canvas
objectReference: {fileID: 0}
- target: {fileID: 6329222681380648929, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_sharedMaterial
value:
objectReference: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
- target: {fileID: 7064072091532955446, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
propertyPath: m_AnchoredPosition.x
value: -6.4188232
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
m_AddedComponents: []
m_SourcePrefab: {fileID: 100100000, guid: dd067b5f8139dbc46b51ef157b0f0613, type: 3}
--- !u!1 &842753593
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 842753595}
- component: {fileID: 842753594}
m_Layer: 0
m_Name: RecourceManager
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &842753594
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 842753593}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5505825aa4d83af49a3a641e48af3ff7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::ResourceManager
startWood: 0
startStone: 0
startIron: 0
startFood: 0
startGold: 0
startGems: 0
startPower: 0
startPopulation: 0
--- !u!4 &842753595
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 842753593}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 746.5865, y: 295.47598, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1080091918
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1080091921}
- component: {fileID: 1080091920}
- component: {fileID: 1080091919}
m_Layer: 0
m_Name: SaveManager
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1080091919
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1080091918}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0f2bb66f2486f084b813736d3a8768d7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::GameBootstrap
--- !u!114 &1080091920
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1080091918}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 749721a5613594941adaed89b39ea5fe, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::SaveManager
autoSaveInterval: 30
--- !u!4 &1080091921
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1080091918}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -11.03001, y: 0.33877, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1225367365
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1225367367}
- component: {fileID: 1225367366}
m_Layer: 0
m_Name: City3D
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1225367366
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1225367365}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 44d822c903694362bef912c055adda5b, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::City3DController
--- !u!4 &1225367367
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1225367365}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1935466178
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1935466180}
- component: {fileID: 1935466179}
m_Layer: 0
m_Name: Sun
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!108 &1935466179
Light:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935466178}
m_Enabled: 1
serializedVersion: 13
m_Type: 1
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_Intensity: 1.2
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize2D: {x: 0.5, y: 0.5}
m_Shadows:
m_Type: 2
m_Resolution: -1
m_CustomResolution: -1
m_Strength: 1
m_Bias: 0.05
m_NormalBias: 0.4
m_NearPlane: 0.2
m_CullingMatrixOverride:
e00: 1
e01: 0
e02: 0
e03: 0
e10: 0
e11: 1
e12: 0
e13: 0
e20: 0
e21: 0
e22: 1
e23: 0
e30: 0
e31: 0
e32: 0
e33: 1
m_UseCullingMatrixOverride: 0
m_Cookie: {fileID: 0}
m_DrawHalo: 0
m_Flare: {fileID: 0}
m_RenderMode: 0
m_CullingMask:
serializedVersion: 2
m_Bits: 4294967295
m_RenderingLayerMask: 1
m_Lightmapping: 4
m_LightShadowCasterMode: 0
m_AreaSize: {x: 1, y: 1}
m_BounceIntensity: 1
m_ColorTemperature: 6570
m_UseColorTemperature: 0
m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
m_UseBoundingSphereOverride: 0
m_UseViewFrustumForShadowCasterCull: 1
m_ForceVisible: 0
m_ShapeRadius: 0.025
m_ShadowAngle: 0
m_LightUnit: 1
m_LuxAtDistance: 1
m_EnableSpotReflector: 1
--- !u!4 &1935466180
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935466178}
serializedVersion: 2
m_LocalRotation: {x: 0.40821794, y: -0.23456973, z: 0.10938166, w: 0.8754261}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 110794174}
- {fileID: 1935466180}
- {fileID: 1225367367}
- {fileID: 429953708}
- {fileID: 1080091921}
- {fileID: 842753595}
- {fileID: 695231209}
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c1c34719f0605c247a3575d077f3d656
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+27 -4
View File
@@ -13,7 +13,7 @@ using UnityEngine;
public class BattleConfig : ScriptableObject
{
[Tooltip("Версия баланса боя. Поменял числа -- подними версию: итог боя запоминает, по какой версии посчитан")]
public int version = 2;
public int version = 4;
[Tooltip("Контра, %: пехота > кавалерия > лучники > пехота. Взвешивается по доле нужного типа у врага")]
public int counterPercent = 25;
@@ -23,11 +23,28 @@ public class BattleConfig : ScriptableObject
public int siegeVsWallsPercent = 50;
[Tooltip("Осадные в поле и в обороне, % (минус)")]
public int siegeInFieldPercent = -25;
[Tooltip("Потери проигравшего, % от каждого типа. Не окончательная формула")]
public int loserLossPercent = 50;
[Tooltip("Случайность каждой стороны: от -N% до +N%. 0 -- без случайности")]
public int randomPercent = 5;
[Header("Кривая потерь (решение №14, регламент 31.10, ТЕСТ)")]
[Tooltip("Опорные точки соотношения сил (сильная / слабая) в десятых долях процента: 1000 = 1:1, 3000 = 3:1. Первая -- обязательно 1000, дальше по возрастанию")]
public int[] lossRatioPermille = { 1000, 3000, 5000, 7000, 10000 };
[Tooltip("Доля выбывших у сильной стороны в каждой точке, десятые доли процента: 450 = 45%")]
public int[] strongLossPermille = { 450, 160, 110, 74, 20 };
[Tooltip("Доля выбывших у слабой стороны в каждой точке, десятые доли процента: 740 = 74%")]
public int[] weakLossPermille = { 450, 740, 880, 920, 950 };
[Tooltip("Разгром дальше последней точки: слабая сторона плавно стремится к этому пределу, десятые доли процента. Решения 31.10, п. 5: 990 = 99%")]
public int weakLossMaxPermille = 990;
[Header("Погибшие и раненые (регламент 31.10, решения 31.10, п. 7, ТЕСТ)")]
[Tooltip("Соотношение сил стороны (своя / чужая) в десятых долях процента: 200 = 1:5, 1000 = 1:1, 3000 = 3:1. По возрастанию")]
public int[] deadRatioPermille = { 200, 500, 1000, 3000 };
[Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")]
public int[] deadSharePermille = { 700, 450, 300, 200 };
[Tooltip("Пропавшие без вести (решения 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%")]
@@ -58,7 +75,13 @@ public class BattleConfig : ScriptableObject
WallsPercent = wallsPercent,
SiegeVsWallsPercent = siegeVsWallsPercent,
SiegeInFieldPercent = siegeInFieldPercent,
LoserLossPercent = loserLossPercent,
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)
+319 -18
View File
@@ -14,6 +14,9 @@ using UnityEngine.UI;
// - Кристалл: где добывается, запас / лимит;
// - Казармы: очередь обучения, какие войска открыты и что нужно для остальных, улучшение (открывает войска),
// кнопка "Нанять войска" (панель "Войска").
// - Стройка и улучшение идут по времени (BuildTimer, 01.11, ТЕСТ): полоска, сколько осталось, ускорение за кристаллы.
// Пока здание строится, в окне только стройка; при улучшении здание работает на старом уровне.
// - У построенных зданий (кроме Ратуши) -- кнопка "Перенести" (бесплатно, уровень и стройка сохраняются). Сноса нет (04.11).
// Без здания (кнопка "Рабочие" в панели DEV и "Все рабочие" в окне) -- список рабочих по всем профессиям.
// Окно -- только интерфейс: рабочих ставит модуль Населения, добычу считает модуль Экономики.
// Пока окно открыто, цифры обновляются сами (раз в полсекунды).
@@ -47,23 +50,64 @@ public static class BuildingWindow
{
if (root != null) UnityEngine.Object.Destroy(root);
live.Clear();
var card = UIKit.Modal(TitleFor(focus), out root);
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);
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, Close, -1, 80, 28);
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()
@@ -190,6 +234,26 @@ public static class BuildingWindow
}
else StorageBar(card, economy, ResourceType.Food);
UIKit.Text(card, "Сверх лимита ресурс не прибавляется — пропадает.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IResourceProtection protection))
{
Header(card, "Защита от грабежа");
var p = EconomyConfig.Load().protection;
ResourceType[] kinds = warehouse
? new[] { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold }
: new[] { ResourceType.Food };
Live(card, () =>
{
var lines = new List<string>();
foreach (var k in kinds)
lines.Add($"{GameData.ResName(k)}: спрятано {protection.Protected(k):0}, можно унести {protection.Lootable(k):0}");
return string.Join("\n", lines.ToArray());
}, 22, UIKit.Theme.Text);
UIKit.Text(card, warehouse
? $"Каждый уровень Склада прячет ещё {p.perWarehouseLevel:0} каждого ресурса — в набеге их не унесут."
: $"Каждый уровень Амбара прячет ещё {p.perGranaryLevel:0} еды — в набеге её не унесут.",
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
}
UpgradeBlock(card, storage.level, $"лимит +{perLevel * storage.level:0} → +{perLevel * (storage.level + 1):0}",
@@ -233,18 +297,45 @@ public static class BuildingWindow
string unlocks = UnlocksAt(next);
if (unlocks.Length > 0) UIKit.Text(card, "Откроется: " + unlocks, 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
string more = MoreBuildingsAt(level, next);
if (more.Length > 0) UIKit.Text(card, "Больше зданий: " + more, 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (TimerRunning(townHall.gameObject)) return; // улучшение уже идёт -- полоска сверху
var cost = BuildingData.ToAmounts(townHall.GetUpgradeCost());
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
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; }
townHall.Upgrade();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Ратуша улучшена до {townHall.currentLevel} уровня");
if (ms > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
if (!GameServices.TryGet(out IEconomy e) || !e.TrySpend(cost)) { UIKit.Toast("Не хватает ресурсов"); return; }
if (ms <= 0) BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, 0, 0).Complete();
else
{
long now = GameClock.UnixNowMs;
BuildTimer.Begin(townHall.gameObject, BuildTimer.Kind.Upgrade, next, now, now + ms);
UIKit.Toast($"Ратуша улучшается до {next} уровня");
}
Rebuild();
}, -1, 80, 26);
b.interactable = canPay;
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());
}
// ---------------- Казармы ----------------
@@ -261,11 +352,11 @@ public static class BuildingWindow
Header(card, "Обучение");
Live(card, () =>
{
var d = gp.Data;
if (d.training.Count == 0) return "Никто не обучается";
var o = d.training[0];
string more = d.training.Count > 1 ? $" (ещё заказов: {d.training.Count - 1})" : "";
return $"{GameData.Troops[o.troopIndex].name} ×{o.count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}";
var training = gp.Training;
if (training.Count == 0) return "Никто не обучается";
var o = training[0];
string more = training.Count > 1 ? $" (ещё заказов: {training.Count - 1})" : "";
return $"{GameData.Troops[o.Type].name} ×{o.Count} — осталось {GameData.FormatTime(gp.TrainingSecondsLeft(o))}{more}";
}, 24, UIKit.Theme.Text);
if (GameServices.TryGet(out IPopulation population))
Live(card, () => $"Можно нанять из жителей: {population.Recruitable} • в Казармах: {population.InTraining}", 22, UIKit.Theme.TextDim);
@@ -298,6 +389,112 @@ public static class BuildingWindow
}, -1, 80, 26);
}
// ---------------- Госпиталь (решения 31.10, п. 7) ----------------
static void HospitalSection(RectTransform card, Hospital hospital)
{
UIKit.Text(card, $"Госпиталь лечит раненых после боёв. Мест: {Hospital.BedsAt(Hospital.MaxLevel)} " +
"(считается самый прокачанный госпиталь). Кто не поместился -- ждёт места.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IArmy army))
{
Header(card, "Раненые");
Live(card, () =>
{
int healingNow = 0, waiting = 0;
long readyMs = long.MaxValue;
foreach (var h in army.Healing) { healingNow += h.Count; readyMs = Math.Min(readyMs, h.EndMs); }
for (int t = 0; t < army.TypeCount; t++) waiting += army.GetWoundedWaiting(t);
if (healingNow + waiting == 0) return "Раненых нет";
string next = readyMs != long.MaxValue
? $", ближайшие вылечатся через {GameData.FormatTime(Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)}"
: "";
return $"Лечатся {healingNow} из {army.HospitalBeds} мест, ждут места {waiting}{next}";
}, 24, UIKit.Theme.Text);
Live(card, () =>
{
var lines = new List<string>();
foreach (var h in army.Healing)
if (h.Type >= 0 && h.Type < GameData.Troops.Count)
lines.Add($"{GameData.Troops[h.Type].name} ×{h.Count} — {GameData.FormatTime(Math.Max(0, h.EndMs - GameClock.UnixNowMs) / 1000.0)}");
return lines.Count > 0 ? string.Join("\n", lines.ToArray()) : "";
}, 22, UIKit.Theme.TextDim);
var rules = HospitalConfig.Load().healGameHours;
UIKit.Text(card, $"Лечение одного воина: пехота и лучники {rules[0]}, кавалерия {rules[2]}, осадные {rules[3]} игровых ч " +
"(игровой час = 5 мин). Лечатся и пока игра закрыта.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
UpgradeBlock(card, hospital.level, $"мест {Hospital.BedsAt(hospital.level)} → {Hospital.BedsAt(hospital.level + 1)}",
() => hospital.Level = hospital.level + 1, Hospital.MaxBuildLevel);
}
// ---------------- Терем богатыря: окно героя ----------------
// Числа героя -- те же, что в панели "Герой" (GameProgress / GameData), механика не меняется.
static void HeroCastleSection(RectTransform card)
{
var gp = GameProgress.Instance;
UIKit.Text(card, "В Тереме живёт богатырь -- верховный командир. Он гуляет по городу; тапните по нему, чтобы поговорить.",
24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
if (gp == null) return;
int lvl = gp.Data.heroLevel, maxLvl = gp.HeroMaxLevel;
var head = UIKit.Row(card, 140, 16);
UIKit.Icon(head, new Color(0.55f, 0.4f, 0.2f), "Б", 130); // место под портрет
var info = UIKit.Card(head, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, $"Уровень {lvl} / {maxLvl}", 30, UIKit.Theme.Gold).fontStyle = FontStyles.Bold;
UIKit.Text(info, $"Сила героя: {GameData.HeroPower(lvl)}", 24, UIKit.Theme.Text);
UIKit.Text(info, $"Бонус к силе армии: +{Mathf.RoundToInt(gp.ArmyBonus * 100)}%", 24, UIKit.Theme.Text);
Header(card, "Навыки");
for (int i = 0; i < GameData.HeroSkillNames.Length; i++)
{
bool has = gp.HasHeroSkill(i);
UIKit.Text(card, has ? $"<color=#9be59b>✓</color> {GameData.HeroSkillNames[i]}"
: $"{GameData.HeroSkillNames[i]} — с {GameData.HeroSkillLevels[i]} ур.",
22, has ? UIKit.Theme.Text : UIKit.Theme.TextDim);
}
Header(card, "Улучшение героя");
if (lvl >= maxLvl)
{
UIKit.Text(card, $"Предел уровня. Улучшите Ратушу до {gp.TownHallLevel + 1} ур., чтобы поднять предел до " +
$"{GameData.HeroMaxLevel(gp.TownHallLevel + 1)}.", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
}
else
{
var cost = GameData.HeroUpgradeCost(lvl);
Live(card, () =>
{
var parts = new List<string>();
foreach (var c in cost)
{
bool enough = gp.Res(c.type) >= c.amount;
parts.Add($"<color={(enough ? "#9be59b" : "#ff7a7a")}>{GameData.ResName(c.type)} {c.amount:N0}</color>");
}
return "Цена: " + string.Join(", ", parts.ToArray());
}, 22, UIKit.Theme.Text);
UIKit.Text(card, $"Дальше: сила {GameData.HeroPower(lvl)} → {GameData.HeroPower(lvl + 1)}, " +
$"бонус армии +{GameData.HeroArmyBonusPercent(lvl)}% → +{GameData.HeroArmyBonusPercent(lvl + 1)}%",
22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var up = UIKit.Button(card, $"Улучшить до {lvl + 1} ур.", UIKit.Theme.Green, () =>
{
if (!gp.CanUpgradeHero) { UIKit.Toast("Не хватает ресурсов"); return; }
gp.UpgradeHero();
Rebuild();
}, -1, 80, 26);
live.Add(() => up.interactable = gp.CanUpgradeHero);
}
UIKit.Button(card, "Подробнее о герое", UIKit.Theme.Blue, () =>
{
Close();
if (UIPanelManager.Instance != null) UIPanelManager.Instance.Open("Hero");
}, -1, 80, 26);
}
// Резерв для найма на уровне Ратуши (те же числа, что у модуля Населения)
static int ReserveAt(int townHallLevel)
{
@@ -320,32 +517,135 @@ public static class BuildingWindow
// ---------------- Улучшение (общее для зданий с уровнем, кроме Ратуши) ----------------
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel)
static void UpgradeBlock(RectTransform card, int level, string effect, Action raiseLevel, int maxLevel = 0)
{
var config = EconomyConfig.Load();
if (level >= config.upgrade.maxLevel)
if (level >= (maxLevel > 0 ? maxLevel : config.upgrade.maxLevel)) // у госпиталя свой предел: I-V
{
UIKit.Text(card, "Максимальный уровень", 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
}
Header(card, $"Улучшение до {level + 1} ур.");
UIKit.Text(card, effect, 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
if (focus != null && TimerRunning(focus)) return; // улучшение уже идёт -- полоска сверху
var cost = config.UpgradeCost(level);
UIKit.Text(card, "Цена: " + CostText(cost), 24, UIKit.Theme.Text, TextAlignmentOptions.Center);
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; }
raiseLevel();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Улучшено до {level + 1} уровня");
if (timed)
{
long now = GameClock.UnixNowMs;
BuildTimer.Begin(building, BuildTimer.Kind.Upgrade, level + 1, now, now + ms);
UIKit.Toast($"Улучшение до {level + 1} уровня началось");
}
else
{
raiseLevel();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
UIKit.Toast($"Улучшено до {level + 1} уровня");
}
Rebuild();
}, -1, 80, 26);
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);
@@ -469,6 +769,7 @@ public class BuildingWindowTicker : MonoBehaviour
void Update()
{
if (Input.GetKeyDown(KeyCode.Escape)) { BuildingWindow.Close(); return; } // "Назад" на телефоне
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + 0.5f;
BuildingWindow.Tick();
+10 -3
View File
@@ -5,7 +5,8 @@ using UnityEngine;
// Слой приложения: сообщает модулям, какие здания стоят в городе.
// - рабочие места (Лес, Еда, Камень, Металл, Золото) и их уровни -> модуль добычи (места и эффективность);
// - Дома (здание "Население") -> модуль Населения (предел жилья);
// - Склады и Амбары -> лимиты склада в Экономике.
// - Склады и Амбары -> лимиты склада в Экономике и защита ресурсов от грабежа (App/ResourceProtection).
// Здания, которые ещё строятся (BuildTimer), не считаются: они пока не работают.
// Модули сами здания не ищут: они без Unity и живут и на карте мира, где города нет.
//
// Проверяет город раз в полсекунды и применяет изменения, только когда картина устоялась
@@ -16,6 +17,10 @@ public class CitySync : MonoBehaviour
public static CitySync Instance { get; private set; }
// Город открыт не в 2D-сцене, а в 3D (City3D): здания с PlacedBuildingId там тоже есть (05.11)
public static bool ExternalCity;
static bool CityOpen => BuildManager.Instance != null || ExternalCity;
float next;
string pending; // замер, который ждёт подтверждения
string applied; // последний применённый замер
@@ -38,7 +43,7 @@ public class CitySync : MonoBehaviour
{
if (Time.unscaledTime < next) return;
next = Time.unscaledTime + Interval;
if (BuildManager.Instance == null) return; // город не открыт -- модули помнят последнее
if (!CityOpen) return; // город не открыт -- модули помнят последнее
var city = Scan();
if (city.Signature == applied) { pending = null; return; }
@@ -49,7 +54,7 @@ public class CitySync : MonoBehaviour
// Применить сразу (после улучшения здания в окне -- чтобы окно показало новые места)
public void SyncNow()
{
if (BuildManager.Instance == null) return;
if (!CityOpen) return;
Apply(Scan());
}
@@ -67,6 +72,7 @@ public class CitySync : MonoBehaviour
foreach (var placed in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
if (string.IsNullOrEmpty(placed.buildingName)) continue; // убирается в этом кадре
if (BuildTimer.IsConstructing(placed)) continue; // ещё строится -- не работает
var rc = placed.GetComponent<ResourceCollector>();
if (rc != null)
@@ -112,6 +118,7 @@ public class CitySync : MonoBehaviour
ResourceType[] stored = { ResourceType.Wood, ResourceType.Stone, ResourceType.Iron, ResourceType.Gold, ResourceType.Food, ResourceType.Gems };
foreach (var t in stored) rm.SetCapacity(t, config.GetCapacity(t, city.WarehouseLevels, city.GranaryLevels));
}
ResourceProtection.SetLevels(city.WarehouseLevels, city.GranaryLevels); // защита от грабежа
var production = GameModules.Production;
var population = GameModules.Population;
+40
View File
@@ -0,0 +1,40 @@
// Стройка и офлайн (решения 04.11): стройка, которая закончилась, пока игра была закрыта, вступает в силу
// в тот момент офлайн-расчёта, когда время дошло до её конца. Тогда добыча здания (и его новый уровень)
// считается ровно с момента завершения, а не с начала офлайна и не с момента входа в игру.
//
// Как: модуль Населения на каждом шаге расчёта сообщает, до какого момента дошёл (GameTimeSimulatedEvent).
// Здесь завершаются стройки с концом не позже этого момента, и город сразу пересчитывается (CitySync) --
// следующий шаг добычи идёт уже с новым зданием. Пока расчёт не дошёл до конца стройки, BuildTimer сам её не
// завершает (BuildTimer.MayComplete). Шаг расчёта -- 10 минут, поэтому добыча начинается не раньше конца
// стройки и не позже чем через 10 минут после него: лишнего не начисляется никогда.
public static class ConstructionClock
{
// До какого момента (мс) расчёт добычи уже дошёл. 0 -- ещё ни разу не считал (сразу после запуска)
public static long SimulatedUntilMs { get; private set; }
static bool subscribed;
public static void Init()
{
SimulatedUntilMs = 0;
BuildTimer.MayComplete = endMs => endMs <= SimulatedUntilMs;
if (subscribed) return;
subscribed = true;
EventBus.Subscribe<GameTimeSimulatedEvent>(OnSimulated);
}
static void OnSimulated(GameTimeSimulatedEvent e)
{
long ms = e.UnixNow * 1000;
if (ms > SimulatedUntilMs) SimulatedUntilMs = ms; // время расчёта только растёт
bool any = false;
foreach (var timer in BuildTimer.All.ToArray())
{
if (timer == null || timer.endMs > SimulatedUntilMs) continue;
timer.Complete(); // уровень только растёт, второй раз не завершается
any = true;
}
if (any && CitySync.Instance != null) CitySync.Instance.SyncNow();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6104b5c531ff479eb569880ebd75d68c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+15
View File
@@ -17,6 +17,7 @@ public class DebugPanel : MonoBehaviour
GameObject panel;
TextMeshProUGUI info;
UnityEngine.UI.Button gridButton;
float nextRefresh;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
@@ -81,11 +82,25 @@ public class DebugPanel : MonoBehaviour
var row4 = UIKit.Row(card, 70, 10);
Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null));
gridButton = UIKit.Button(row4, GridLabel, UIKit.Theme.Blue, ToggleGrid, -1, 70, 24);
UIKit.Size(gridButton, -1, 70, 1);
Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false));
panel.SetActive(false);
}
// Отладочная сетка стройки (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);
+113 -1
View File
@@ -24,8 +24,11 @@ public class EconomyConfig : ScriptableObject
[Tooltip("Еды в час на одного жителя")]
public float foodPerCivilianPerHour = 0.1f;
[Tooltip("Еды в час на одного воина")]
[Tooltip("Еды в час на одного воина (и новобранца в Казармах), если норма по типам не задана")]
public float foodPerSoldierPerHour = 0.2f;
[Tooltip("Еды в час на воина по типам: пехота, лучники, кавалерия, осадные. Решения 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15. " +
"Раненые едят так же, даже ожидая койку. Пусто -- все по foodPerSoldierPerHour")]
public float[] foodPerTroopTypePerHour = { 0.15f, 0.2f, 0.15f, 0.15f };
[Tooltip("Прирост людей в час, если есть еда (документ: 2–3)")]
public float growthPerHour = 2.5f;
@@ -123,6 +126,48 @@ public class EconomyConfig : ScriptableObject
public int[] requiredBarracksLevel = { 1, 2, 3, 5 };
}
[Serializable]
public class ConstructionSettings // время стройки и улучшения, строители (01.11, ТЕСТ)
{
[Tooltip("Сколько строек и улучшений идёт одновременно (строители). ТЕСТ")]
public int builders = 2;
[Tooltip("Постройка нового здания, секунд. ТЕСТ")]
public int buildSeconds = 60;
[Tooltip("Улучшение до уровня 2, 3, 4... (секунд): [0] -- до 2 ур., [1] -- до 3 ур. ... ТЕСТ")]
public int[] upgradeSecondsToLevel = { 60, 120, 300, 600, 1200, 1800, 3600, 5400, 7200 };
[Tooltip("Ратуша строится дольше остальных: множитель времени улучшения. ТЕСТ")]
public float townHallTimeMultiplier = 2f;
[Tooltip("Ускорение: секунд стройки за 1 кристалл. ТЕСТ")]
public int secondsPerGem = 30;
// Сноса зданий нет (решение 04.11): только бесплатный перенос
}
[Serializable]
public class BuildingLimit
{
public string buildingName;
[Tooltip("Сколько таких зданий можно иметь на Ратуше 1, 2, 3 ... уровня. 0 -- нельзя")]
public int[] maxByTownHall;
public BuildingLimit() { }
public BuildingLimit(string name, params int[] max) { buildingName = name; maxByTownHall = max; }
}
[Serializable]
public class ProtectionSettings // защита ресурсов от грабежа (01.11, ТЕСТ)
{
[Tooltip("Защищено без Складов: дерево, камень, металл, золото. ТЕСТ")]
public float baseGoods = 100f;
[Tooltip("+ к защите дерева, камня, металла, золота за каждый уровень Склада. ТЕСТ")]
public float perWarehouseLevel = 200f;
[Tooltip("Защищено без Амбаров: еда. ТЕСТ")]
public float baseFood = 100f;
[Tooltip("+ к защите еды за каждый уровень Амбара. ТЕСТ")]
public float perGranaryLevel = 300f;
[Tooltip("Кристаллы: без технологии «Грабёж» нападающий не уносит ничего (решение 04.11). С ней -- не больше этой доли запаса. ТЕСТ, точные ограничения определят отдельно")]
[Range(0f, 1f)] public float plunderGemsShare = 0.1f;
}
[Serializable]
public class StartResources
{
@@ -156,6 +201,30 @@ public class EconomyConfig : ScriptableObject
[Header("Солдат T1 (этап 5)")]
public SoldierSettings soldier = new SoldierSettings();
[Header("Стройка: время, строители, снос (ТЕСТ)")]
public ConstructionSettings construction = new ConstructionSettings();
[Header("Лимит зданий по уровню Ратуши (ТЕСТ). Здания не из списка -- без лимита")]
public BuildingLimit[] buildingLimits =
{
// Ратуша: 1 2 3 4 5 6 7 8 9 10
new BuildingLimit("Лес", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
new BuildingLimit("Еда", 2, 3, 3, 4, 4, 5, 5, 6, 6, 7),
new BuildingLimit("Камень", 2, 2, 3, 3, 4, 4, 5, 5, 6, 6),
new BuildingLimit("Металл", 0, 0, 1, 1, 2, 2, 3, 3, 4, 4),
new BuildingLimit("Золото", 0, 1, 1, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Население", 3, 4, 5, 6, 7, 8, 9, 10, 11, 12),
new BuildingLimit("Склад", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Амбар", 1, 1, 2, 2, 2, 3, 3, 3, 4, 4),
new BuildingLimit("Казармы", 1, 1, 2, 2, 3, 3, 3, 3, 3, 3),
new BuildingLimit("Госпиталь", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Терем богатыря", 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
new BuildingLimit("Кристалл", 0, 0, 0, 0, 0, 1, 1, 1, 1, 1),
};
[Header("Защита ресурсов от грабежа (ТЕСТ)")]
public ProtectionSettings protection = new ProtectionSettings();
// ---------------- Загрузка ----------------
static EconomyConfig cached;
@@ -189,6 +258,49 @@ public class EconomyConfig : ScriptableObject
}
}
// Сколько зданий с таким именем можно иметь на этом уровне Ратуши. -1 -- без лимита (здания нет в списке)
public int BuildingLimitFor(string buildingName, int townHallLevel)
{
if (buildingLimits == null || string.IsNullOrEmpty(buildingName)) return -1;
foreach (var l in buildingLimits)
{
if (l == null || l.buildingName != buildingName || l.maxByTownHall == null || l.maxByTownHall.Length == 0) continue;
return Mathf.Max(0, l.maxByTownHall[Mathf.Clamp(townHallLevel, 1, l.maxByTownHall.Length) - 1]);
}
return -1;
}
// Время стройки (targetLevel = 1) или улучшения до targetLevel, мс. Ратуша -- с множителем
public long ConstructionMs(bool townHall, int targetLevel)
{
var c = construction;
double seconds;
if (targetLevel <= 1) seconds = c.buildSeconds;
else
{
var t = c.upgradeSecondsToLevel;
seconds = t == null || t.Length == 0 ? c.buildSeconds : t[Mathf.Clamp(targetLevel - 2, 0, t.Length - 1)];
}
if (townHall) seconds *= Mathf.Max(0.01f, c.townHallTimeMultiplier);
return (long)(Math.Max(0, seconds) * 1000.0);
}
// Сколько ресурса не отнять при грабеже: часть запаса, которую прячут Склады и Амбары
public float GetProtected(ResourceType type, int warehouseLevels, int granaryLevels)
{
var p = protection;
switch (type)
{
case ResourceType.Wood:
case ResourceType.Stone:
case ResourceType.Iron:
case ResourceType.Gold: return p.baseGoods + p.perWarehouseLevel * warehouseLevels;
case ResourceType.Food: return p.baseFood + p.perGranaryLevel * granaryLevels;
case ResourceType.Gems: return float.PositiveInfinity; // без «Грабежа» кристаллы не грабятся (с ним -- ResourceProtection)
default: return 0f;
}
}
// Цена улучшения здания с уровня level на level + 1
public ResourceAmount[] UpgradeCost(int level)
{
+110 -1
View File
@@ -22,12 +22,52 @@ public static class GameModules
for (int i = 0; i < TroopType.Count && i < GameData.Troops.Count; i++) unitPower[i] = GameData.Troops[i].power;
GameServices.Register<IBattleSystem>(new BattleSystem(BattleConfig.Load().ToRules(unitPower)));
// Марши: скорости по типам -- в модуль Мира, слоты похода -- в модуль Армии (таблица MarchConfig)
var marchConfig = MarchConfig.Load();
MarchManager.SpeedRules = marchConfig.ToSpeedRules();
MarchManager.PointRules = WorldResourceConfig.Load().ToRules(); // ресурсные точки (регламент 31.10)
MarchManager.Patrols = marchConfig.ToPatrolRules(); // патрули-заглушки соседей (Разведка М1)
// Разведка -- чистый C#: туман, обзор, чужие армии (секция "intelligence"). Карту и армии читает у Мира
// через IWorldMap; досчитывает наблюдение перед каждым расчётом мира (WorldAdvancingEvent)
var intelConfig = IntelligenceConfig.Load();
var intelligence = new IntelligenceService(intelConfig.ToRules());
GameServices.Register<IIntelligence>(intelligence);
SaveRegistry.Register(intelligence);
EventBus.Subscribe<WorldAdvancingEvent>(e => intelligence.ObserveUntil(e.ToMs));
// «Обзор» для теста, пока нет дерева технологий (после загрузки и в новой игре)
void TestObservation() { if (intelConfig.testObservationLevel > 0) intelligence.ObservationLevel = intelConfig.testObservationLevel; }
TestObservation();
EventBus.Subscribe<GameLoadedEvent>(_ => TestObservation());
EventBus.Subscribe<GameResetEvent>(_ => TestObservation());
// Армия -- чистый C#, единственное место хранения войск (секция "army")
var army = new ArmyService();
var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules(), 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();
@@ -52,6 +92,21 @@ public static class GameModules
StorageBuilding.Tapped += BuildingWindow.OnStorageTapped;
TownHall.Tapped -= BuildingWindow.OnTownHallTapped;
TownHall.Tapped += BuildingWindow.OnTownHallTapped;
// Стройка и улучшение по времени, строители (01.11, ТЕСТ): числа -- EconomyConfig, раздел "Стройка"
BuildTimer.Builders = Mathf.Max(1, config.construction.builders);
ConstructionClock.Init(); // офлайн: стройка вступает в силу в момент своего завершения (решения 04.11)
BuildTimer.DurationMs = (townHall, level) => EconomyConfig.Load().ConstructionMs(townHall, level);
BuildTimer.Tapped -= OnBuildTimerTapped;
BuildTimer.Tapped += OnBuildTimerTapped;
BuildTimer.Completed -= OnBuildCompleted;
BuildTimer.Completed += OnBuildCompleted;
// 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()); // воины -- тоже люди
@@ -71,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);
@@ -80,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()
@@ -135,6 +212,36 @@ public static class GameModules
GameProgress.Instance.MarkChanged();
}
// Очередь обучения раньше вёл GameProgress (progress.json, Data.training, время -- тики реальных часов).
// Теперь -- Армия (save.json, "army", мс часов игры). Переносим один раз, с тем же оставшимся временем.
static void MigrateLegacyTraining(ArmyService army)
{
var data = GameProgress.Instance.Data;
if (data.training == null || data.training.Count == 0) return;
// В Армии уже есть заказы -- перенос уже был (сохранение записалось, а progress.json -- ещё нет): только убрать старое
if (army.Training.Count == 0)
{
long epochTicks = new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc).Ticks;
long offsetMs = GameClock.OffsetSeconds * 1000; // старые тики -- реальное время, Армия -- часы игры
var orders = new System.Collections.Generic.List<(int, int, long, long, bool)>();
foreach (var o in data.training)
orders.Add((o.troopIndex, o.count, (o.startTicks - epochTicks) / 10_000 + offsetMs, (o.endTicks - epochTicks) / 10_000 + offsetMs, o.recruited));
army.ImportLegacyTraining(orders);
Debug.Log($"[МОДУЛИ] Очередь обучения перенесена из progress.json в модуль Армии: заказов {orders.Count}.");
}
// Из progress.json убираем, только когда очередь уже записана в save.json -- иначе вылет игры её потеряет
EventBus.Subscribe<GameSavedEvent>(ClearLegacyTraining);
}
static void ClearLegacyTraining(GameSavedEvent e)
{
EventBus.Unsubscribe<GameSavedEvent>(ClearLegacyTraining);
GameProgress.Instance.Data.training.Clear();
GameProgress.Instance.MarkChanged();
}
// ---------------- Население и экономика ----------------
static PopulationRules ToPopulationRules(EconomyConfig config)
@@ -147,6 +254,8 @@ public static class GameModules
StartSoldiers = p.startSoldiers,
FoodPerCivilianPerHour = p.foodPerCivilianPerHour,
FoodPerSoldierPerHour = p.foodPerSoldierPerHour,
FoodPerTroopTypePerHour = p.foodPerTroopTypePerHour != null && p.foodPerTroopTypePerHour.Length > 0
? System.Array.ConvertAll(p.foodPerTroopTypePerHour, f => (double)f) : null,
GrowthPerHour = p.growthPerHour,
HousingBase = p.housingBase,
HousingPerTownHallLevel = p.housingPerTownHallLevel,
+50
View File
@@ -0,0 +1,50 @@
using UnityEngine;
// Версия игры на экране (05.11, требование Миши: в каждом билде видно, что это за версия).
// В углу экрана: версия из Player Settings (Application.version) и сборка -- ветка, коммит, дата.
// Сборку пишет Editor/BuildInfoWriter перед билдом в Resources/BuildInfo.txt; в редакторе -- "редактор".
// Создаётся сам при запуске, живёт между сценами.
public class GameVersionLabel : MonoBehaviour
{
static GameVersionLabel instance;
string text;
GUIStyle style, shadow;
public static string BuildInfo
{
get
{
var asset = Resources.Load<TextAsset>("BuildInfo");
return asset != null ? asset.text.Trim() : "редактор";
}
}
public static string FullVersion => $"v{Application.version} • {BuildInfo}";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Create()
{
if (instance != null) return;
var go = new GameObject("GameVersionLabel");
DontDestroyOnLoad(go);
instance = go.AddComponent<GameVersionLabel>();
instance.text = FullVersion;
Debug.Log("[ВЕРСИЯ] " + instance.text);
}
void OnGUI()
{
if (style == null)
{
int size = Mathf.Max(12, Screen.height / 60);
style = new GUIStyle(GUI.skin.label) { fontSize = size, alignment = TextAnchor.LowerLeft };
style.normal.textColor = new Color(1f, 1f, 1f, 0.75f);
shadow = new GUIStyle(style);
shadow.normal.textColor = new Color(0f, 0f, 0f, 0.6f);
}
GUI.depth = -1000; // поверх всего
var r = new Rect(8, Screen.height - style.fontSize * 2 - 4, Screen.width * 0.6f, style.fontSize * 2);
GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), text, shadow);
GUI.Label(r, text, style);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4dae86c365fe55948b878547633d712a
+89
View File
@@ -0,0 +1,89 @@
using UnityEngine;
// ТАБЛИЦА ГОСПИТАЛЯ: места и время лечения раненых (регламент 31.10, решения по 8 вопросам Саши от 31.10, п. 7). Числа -- ТЕСТ.
//
// Ассет: Assets/Resources/HospitalConfig.asset (выдели его в окне Project и меняй числа в Inspector;
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
// Create -> Bogatyri -> Hospital Config, имя файла -- HospitalConfig, папка -- Resources.
//
// Модуль Армии таблицу не видит: App/GameModules передаёт числа в ArmyService.
// Места даёт здание «Госпиталь» (Meta/Build/Hospital.cs) через контракт IHospital.
[CreateAssetMenu(fileName = "HospitalConfig", menuName = "Bogatyri/Hospital Config")]
public class HospitalConfig : ScriptableObject
{
[Header("Здание «Госпиталь» (ТЕСТ)")]
[Tooltip("Мест для лечения по уровням госпиталя I..V. Регламент 31.10: 20..60. Число строк -- число уровней")]
public int[] bedsByLevel = { 20, 30, 40, 50, 60 };
[Tooltip("Мест, пока в городе нет госпиталя. Решения 01.11, п. 3: 5. После постройки заменяются штатными, не складываются")]
public int temporaryBeds = 5;
[Tooltip("С какого уровня Ратуши можно строить")]
public int requiredTownHallLevel = 1;
[Tooltip("Цена постройки: дерево")]
public int buildWood = 250;
[Tooltip("Цена постройки: камень")]
public int buildStone = 150;
[Header("Лечение одного воина, ИГРОВЫХ часов (1 игровой час = 5 реальных минут)")]
[Tooltip("Пехота, лучники, кавалерия, осадные. Регламент 31.10: 4 / 4 / 6 / 8")]
public int[] healGameHours = { 4, 4, 6, 8 };
[Header("Цена лечения одного воина (решения 01.11, п. 3: золото 1 / 1 / 2 / 3)")]
[Tooltip("Еды за воина: пехота, лучники, кавалерия, осадные")]
public int[] healFoodPerSoldier = { 0, 0, 0, 0 };
[Tooltip("Золота за воина: пехота, лучники, кавалерия, осадные")]
public int[] healGoldPerSoldier = { 1, 1, 2, 3 };
[Header("Пропавшие без вести (решения 01.11, п. 5). Долю пропавших в бою задаёт BattleConfig.missingSharePermille")]
[Tooltip("Сколько пропавших вернётся, %. Утверждён диапазон 10-90%, формула НЕ утверждена -- ТЕСТ")]
public int missingReturnPercent = 50;
[Tooltip("Из вернувшихся ранены, %. Утверждено: 30 (70% здоровыми)")]
public int missingWoundedPercent = 30;
[Tooltip("Окно возвращения после обратного пути, ИГРОВЫХ часов. Утверждено: до 6")]
public int missingWindowGameHours = 6;
[Tooltip("Возвращаются группами: от 2 до стольких. Утверждено: 2-4")]
public int missingMaxGroups = 4;
static HospitalConfig cached;
public static HospitalConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<HospitalConfig>("HospitalConfig");
if (cached == null)
{
Debug.LogWarning("[ГОСПИТАЛЬ] Нет Assets/Resources/HospitalConfig.asset -- берутся числа по умолчанию из HospitalConfig.cs.");
cached = CreateInstance<HospitalConfig>();
}
return cached;
}
public MissingRules ToMissingRules()
{
return new MissingRules
{
ReturnPercent = Mathf.Clamp(missingReturnPercent, 0, 100),
WoundedPercent = Mathf.Clamp(missingWoundedPercent, 0, 100),
WindowGameHours = Mathf.Max(0, missingWindowGameHours),
MaxGroups = Mathf.Max(2, missingMaxGroups)
};
}
public HospitalRules ToRules()
{
var hours = new int[TroopType.Count];
var cost = new ResourceAmount[TroopType.Count][];
for (int i = 0; i < TroopType.Count; i++)
{
hours[i] = healGameHours != null && i < healGameHours.Length ? Mathf.Max(0, healGameHours[i]) : 4;
int food = healFoodPerSoldier != null && i < healFoodPerSoldier.Length ? Mathf.Max(0, healFoodPerSoldier[i]) : 0;
int gold = healGoldPerSoldier != null && i < healGoldPerSoldier.Length ? Mathf.Max(0, healGoldPerSoldier[i]) : 0;
cost[i] = new[] { new ResourceAmount(ResourceType.Food, food), new ResourceAmount(ResourceType.Gold, gold) };
}
return new HospitalRules
{
TemporaryBeds = Mathf.Max(0, temporaryBeds),
HealGameHours = hours,
HealCostPerSoldier = cost
};
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c25bbe993b314a15a6d1a8aaf0017d55
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+73
View File
@@ -0,0 +1,73 @@
using UnityEngine;
// ТАБЛИЦА РАЗВЕДКИ, этап М1: радиусы обзора, последний контакт, примерная численность, опознание (ТЗ разведки 31.10).
// Числа -- ТЕСТ.
//
// Ассет: Assets/Resources/IntelligenceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
// Create -> Bogatyri -> Intelligence Config, имя файла -- IntelligenceConfig, папка -- Resources.
//
// Модуль таблицу не видит: App/GameModules передаёт числа в модуль Разведки.
// Патрули-заглушки соседей включаются в таблице MarchConfig (это походы Мира).
[CreateAssetMenu(fileName = "IntelligenceConfig", menuName = "Bogatyri/Intelligence Config")]
public class IntelligenceConfig : ScriptableObject
{
[Header("Радиус обзора, клеток карты")]
[Tooltip("Своё поселение. ТЗ разведки: 5")]
public float settlementRadius = 5f;
[Tooltip("Обычная армия в походе. ТЗ разведки: 1")]
public float armyRadius = 1f;
[Header("Технология «Обзор» (не складывается, уровень заменяет)")]
[Tooltip("Прибавка к радиусам на уровнях I, II, III, %. ТЗ: 10 / 20 / 30")]
public int[] observationPercent = { 10, 20, 30 };
[Tooltip("ТОЛЬКО ДЛЯ ТЕСТА: уровень «Обзора» 0..3, пока нет дерева технологий")]
public int testObservationLevel = 0;
[Header("Чужие армии")]
[Tooltip("Метка последнего контакта живёт, РЕАЛЬНЫХ минут. ТЗ: 10")]
public float lastContactMinutes = 10f;
[Tooltip("Примерная численность: ±%. ТЗ: 20")]
public int approxPercent = 20;
[Tooltip("Шанс опознать владельца за одно событие наблюдения, %. ТЗ: 50")]
public int identifyPercent = 50;
[Tooltip("Шаг расчёта наблюдения за время, пока игра была закрыта, РЕАЛЬНЫХ секунд")]
public float sampleStepSeconds = 10f;
[Header("Туман на карте")]
[Tooltip("Выключено (по умолчанию): города, лагеря и точки видны всегда, туман только затемняет землю. " +
"Включено: неисследованная местность закрыта полностью вместе с объектами. Решения 31.10, п. 1: для М1 не требуется, оставлено переключателем")]
public bool hideUnexploredObjects = false;
static IntelligenceConfig cached;
public static IntelligenceConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<IntelligenceConfig>("IntelligenceConfig");
if (cached == null)
{
Debug.LogWarning("[РАЗВЕДКА] Нет Assets/Resources/IntelligenceConfig.asset -- берутся числа по умолчанию из IntelligenceConfig.cs.");
cached = CreateInstance<IntelligenceConfig>();
}
return cached;
}
public IntelligenceRules ToRules()
{
var levels = new int[4];
for (int i = 0; i < 3 && observationPercent != null && i < observationPercent.Length; i++) levels[i + 1] = Mathf.Max(0, observationPercent[i]);
return new IntelligenceRules
{
SettlementRadius = Milli(settlementRadius),
ArmyRadius = Milli(armyRadius),
ObservationPercent = levels,
LastContactMs = (long)(Mathf.Max(0f, lastContactMinutes) * 60_000f),
ApproxPercent = Mathf.Clamp(approxPercent, 0, 100),
IdentifyPercent = Mathf.Clamp(identifyPercent, 0, 100),
SampleStepMs = (long)(Mathf.Max(1f, sampleStepSeconds) * 1000f)
};
}
static int Milli(float cells) => Mathf.Max(0, Mathf.RoundToInt(cells * 1000f));
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c249ad34e2f94460b9f99b49c71760c8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+82
View File
@@ -0,0 +1,82 @@
using UnityEngine;
// ТАБЛИЦА МАРШЕЙ: скорости войск по типам и слоты похода (регламент 31.10, раздел 7). Числа -- ТЕСТ.
//
// Ассет: Assets/Resources/MarchConfig.asset (выдели его в окне Project и меняй числа в Inspector;
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
// Create -> Bogatyri -> March Config, имя файла -- MarchConfig, папка -- Resources.
//
// Модули таблицу не видят: App/GameModules передаёт скорости в модуль Мира, слоты -- в модуль Армии.
// Бонусы героя (+15%) и технологии (+20%) к скорости появятся вместе с героями и технологиями.
[CreateAssetMenu(fileName = "MarchConfig", menuName = "Bogatyri/March Config")]
public class MarchConfig : ScriptableObject
{
[Header("Скорость: клеток карты за 1 РЕАЛЬНЫЙ час (марш идёт в реальном времени)")]
[Tooltip("Пехота. Регламент 31.10: 8")]
public float infantry = 8f;
[Tooltip("Лучники. Регламент 31.10: 9")]
public float archers = 9f;
[Tooltip("Кавалерия. Регламент 31.10: 12")]
public float cavalry = 12f;
[Tooltip("Осадные. Регламент 31.10: 4")]
public float siege = 4f;
[Header("Модификаторы скорости, % (решения 31.10, п. 4: смешанное войско -- средняя по численности)")]
[Tooltip("По типам: пехота, лучники, кавалерия, осадные. +10 -- быстрее на 10%. Позже -- бонусы героев и технологий. ТЕСТ")]
public int[] typeSpeedBonusPercent = { 0, 0, 0, 0 };
[Tooltip("Общий модификатор всего войска, применяется один раз. ТЕСТ")]
public int armySpeedBonusPercent = 0;
[Header("Слоты похода")]
[Tooltip("Сколько походов игрока могут идти одновременно. Регламент 31.10: 2 (технология «Военная логистика» -- до 7)")]
public int baseMarchSlots = 2;
[Header("Патрули-заглушки соседей (Разведка М1, ВРЕМЕННАЯ тестовая механика)")]
[Tooltip("Соседние города иногда отправляют отряды к другим городам и лагерям -- проверить обнаружение, численность, опознание и потерю контакта. Выключить, когда появится настоящий PvP")]
public bool patrolsEnabled = true;
[Tooltip("Сколько патрулей одновременно на карте")]
public int patrolMaxActive = 3;
[Tooltip("Новый патруль -- раз в столько РЕАЛЬНЫХ минут (если не превышен предел)")]
public float patrolIntervalMinutes = 10f;
public int patrolSoldiersMin = 10;
public int patrolSoldiersMax = 40;
static MarchConfig cached;
public static MarchConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<MarchConfig>("MarchConfig");
if (cached == null)
{
Debug.LogWarning("[МАРШИ] Нет Assets/Resources/MarchConfig.asset -- берутся числа по умолчанию из MarchConfig.cs.");
cached = CreateInstance<MarchConfig>();
}
return cached;
}
// Скорости для модуля Мира: милли-единиц карты (1000 = клетка) за реальный час
public Bogatyri.World.MarchSpeedRules ToSpeedRules()
{
return new Bogatyri.World.MarchSpeedRules
{
SpeedMilliPerHour = new[] { Milli(infantry), Milli(archers), Milli(cavalry), Milli(siege) },
TypeBonusPercent = (int[])(typeSpeedBonusPercent ?? new int[4]).Clone(),
ArmyBonusPercent = armySpeedBonusPercent
};
}
public Bogatyri.World.PatrolRules ToPatrolRules()
{
return new Bogatyri.World.PatrolRules
{
Enabled = patrolsEnabled,
MaxActive = Mathf.Max(0, patrolMaxActive),
IntervalMs = (long)(Mathf.Max(1f, patrolIntervalMinutes) * 60_000f),
SoldiersMin = Mathf.Max(1, patrolSoldiersMin),
SoldiersMax = Mathf.Max(1, patrolSoldiersMax)
};
}
static int Milli(float cellsPerHour) => Mathf.Max(1, Mathf.RoundToInt(cellsPerHour * 1000f));
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0427eef4612041d2b8a359f9bf358779
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+33
View File
@@ -0,0 +1,33 @@
using UnityEngine;
// Защита ресурсов от грабежа (контракт IResourceProtection, 01.11, ТЕСТ).
// Часть запаса прячут Склады и Амбары: EconomyConfig, раздел "Защита ресурсов". Уровни зданий сообщает
// App/CitySync; на карте мира, где города нет, действуют последние известные уровни.
public sealed class ResourceProtection : IResourceProtection
{
public static int WarehouseLevels { get; private set; }
public static int GranaryLevels { get; private set; }
public static void SetLevels(int warehouseLevels, int granaryLevels)
{
WarehouseLevels = Mathf.Max(0, warehouseLevels);
GranaryLevels = Mathf.Max(0, granaryLevels);
}
public float Protected(ResourceType type)
{
return EconomyConfig.Load().GetProtected(type, WarehouseLevels, GranaryLevels);
}
public float Lootable(ResourceType type) => Lootable(type, false);
// Кристаллы: без «Грабежа» -- 0; с ним -- не больше доли запаса (EconomyConfig, ТЕСТ, точные ограничения -- позже)
public float Lootable(ResourceType type, bool attackerHasPlunder)
{
if (!GameServices.TryGet(out IEconomy economy)) return 0f;
float have = Mathf.Max(0f, economy.Get(type));
if (type == ResourceType.Gems)
return attackerHasPlunder ? Mathf.Floor(have * Mathf.Clamp01(EconomyConfig.Load().protection.plunderGemsShare)) : 0f;
return Mathf.Max(0f, have - Protected(type));
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cea38890d8f5d8d4f804c095c4c113b4
+84
View File
@@ -0,0 +1,84 @@
using UnityEngine;
// ТАБЛИЦА РЕСУРСНЫХ ТОЧЕК (регламент 31.10, раздел 7: «Изменять числа через WorldResourceConfig.asset»). Числа -- ТЕСТ.
//
// Ассет: Assets/Resources/WorldResourceConfig.asset (выдели его в окне Project и меняй числа в Inspector;
// изменения действуют со следующего запуска игры). Создать заново: правый клик в Project ->
// Create -> Bogatyri -> World Resource Config, имя файла -- WorldResourceConfig, папка -- Resources.
//
// Модуль Мира таблицу не видит: App/GameModules переводит её в ResourcePointRules.
// Состояние точек (запас, сроки) ведёт Мир; зачисление добычи и складские лимиты -- Экономика (решение №46).
[CreateAssetMenu(fileName = "WorldResourceConfig", menuName = "Bogatyri/World Resource Config")]
public class WorldResourceConfig : ScriptableObject
{
[Header("Запас точки: малая / средняя / крупная")]
public int[] woodStock = { 200, 600, 1500 };
public int[] stoneStock = { 150, 450, 1000 };
public int[] metalStock = { 80, 250, 600 };
public int[] goldStock = { 30, 100, 250 };
[Header("Добыча одного воина за ИГРОВОЙ час (1 игровой час = 5 реальных минут)")]
public float woodPerGameHour = 2f;
public float stonePerGameHour = 1.5f;
public float metalPerGameHour = 1f;
public float goldPerGameHour = 0.5f;
[Header("Груз одного воина (осадные машины груза не несут)")]
public int woodCargo = 12;
public int stoneCargo = 10;
public int metalCargo = 8;
public int goldCargo = 5;
[Header("Срок жизни точки, РЕАЛЬНЫХ часов с момента появления: малая / средняя / крупная")]
public int[] lifetimeRealHours = { 12, 24, 48 };
[Header("Сколько точек на карте: малых / средних / крупных (решения 31.10, п. 6, ТЕСТ)")]
[Tooltip("Решения 31.10: всего 24 -- лес 8, камень 7, металл 5, золото 4; по размерам 12 / 8 / 4. Раскладка по ресурсам -- здесь")]
public int[] woodCount = { 4, 3, 1 };
public int[] stoneCount = { 4, 2, 1 };
public int[] metalCount = { 2, 2, 1 };
public int[] goldCount = { 2, 1, 1 };
static WorldResourceConfig cached;
public static WorldResourceConfig Load()
{
if (cached != null) return cached;
cached = Resources.Load<WorldResourceConfig>("WorldResourceConfig");
if (cached == null)
{
Debug.LogWarning("[РЕСУРСНЫЕ ТОЧКИ] Нет Assets/Resources/WorldResourceConfig.asset -- берутся числа по умолчанию.");
cached = CreateInstance<WorldResourceConfig>();
}
return cached;
}
public Bogatyri.World.ResourcePointRules ToRules()
{
var d = Bogatyri.World.ResourcePointRules.Default;
return new Bogatyri.World.ResourcePointRules
{
Stock = new[] { Three(woodStock, d.Stock[0]), Three(stoneStock, d.Stock[1]), Three(metalStock, d.Stock[2]), Three(goldStock, d.Stock[3]) },
MiningMilliPerGameHour = new[] { Milli(woodPerGameHour), Milli(stonePerGameHour), Milli(metalPerGameHour), Milli(goldPerGameHour) },
CargoPerSoldier = new[] { Mathf.Max(0, woodCargo), Mathf.Max(0, stoneCargo), Mathf.Max(0, metalCargo), Mathf.Max(0, goldCargo) },
LifetimeRealHours = Three(lifetimeRealHours, d.LifetimeRealHours),
Count = new[] { Counts(woodCount, d.Count[0]), Counts(stoneCount, d.Count[1]), Counts(metalCount, d.Count[2]), Counts(goldCount, d.Count[3]) }
};
}
static int Milli(float perHour) => Mathf.Max(0, Mathf.RoundToInt(perHour * 1000f));
// Число точек: ровно три значения, не меньше 0 (0 -- такого размера нет), иначе -- значения по умолчанию
static int[] Counts(int[] values, int[] fallback)
{
if (values == null || values.Length != 3) return (int[])fallback.Clone();
return new[] { Mathf.Max(0, values[0]), Mathf.Max(0, values[1]), Mathf.Max(0, values[2]) };
}
// Ровно три значения (малая / средняя / крупная), иначе -- значения по умолчанию
static int[] Three(int[] values, int[] fallback)
{
if (values == null || values.Length != 3) return (int[])fallback.Clone();
return new[] { Mathf.Max(1, values[0]), Mathf.Max(1, values[1]), Mathf.Max(1, values[2]) };
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ee3759b377140c0b759a4a386327510
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+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
+141
View File
@@ -0,0 +1,141 @@
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;
void Awake()
{
cam = GetComponent<Camera>();
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:
+85
View File
@@ -0,0 +1,85 @@
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>();
}
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>();
}
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
+5 -5
View File
@@ -66,16 +66,16 @@ public class Barracks : MonoBehaviour, ILeveledBuilding
void Refresh()
{
if (label == null) return;
var d = GameProgress.Instance.Data;
if (d.training.Count == 0)
var training = GameProgress.Instance.Training;
if (training.Count == 0)
{
label.text = "Казармы\n<size=70%>нажмите, чтобы нанять</size>";
return;
}
var o = d.training[0];
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.troopIndex].name + " ×" + o.count + " " +
var o = training[0];
label.text = "Казармы\n<size=70%>" + GameData.Troops[o.Type].name + " ×" + o.Count + " " +
GameData.FormatTime(GameProgress.Instance.TrainingSecondsLeft(o)) +
(d.training.Count > 1 ? " (+" + (d.training.Count - 1) + ")" : "") + "</size>";
(training.Count > 1 ? " (+" + (training.Count - 1) + ")" : "") + "</size>";
}
Vector3 downPos;
+135 -15
View File
@@ -13,6 +13,10 @@ using TMPro;
// 4. Внизу панель: "Поставить" (если место свободно и хватает ресурсов), "Другое здание", "Отмена".
// 5. После постройки режим остаётся включённым -- можно строить дальше. "Выйти" -- сетка прячется.
//
// 6. Лимит зданий по уровню Ратуши (EconomyConfig, ТЕСТ): в списке видно "2 / 3", сверх лимита строить нельзя.
// 7. Стройка идёт по времени и занимает строителя (BuildTimer): все заняты -- поставить нельзя.
// 8. Перенос (кнопка "Перенести" в окне здания): тот же призрак, но здание переезжает бесплатно.
//
// Вешается билдером (Tools -> Build HUD Panels) на HUD_Canvas, кнопка Nav_Build вызывает ToggleMode().
[DefaultExecutionOrder(-50)] // раньше камеры: захват пальца призраком должен быть виден ей в том же кадре
public class BuildModeController : MonoBehaviour
@@ -26,6 +30,13 @@ 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;
@@ -33,6 +44,7 @@ public class BuildModeController : MonoBehaviour
Button placeButton;
BuildGhost ghost;
GameObject moving; // здание, которое переносим (null -- строим новое)
bool dragging;
Vector2 grabOffset;
Vector2 downPos;
@@ -56,15 +68,20 @@ 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();
CancelGhost();
UpdateButtonLabel();
BuildGrid.Instance.ShowOverlay(active);
if (Has2DCity) BuildGrid.Instance.ShowOverlay(active);
if (banner != null) { Destroy(banner); banner = null; }
if (active)
@@ -145,6 +162,33 @@ public class BuildModeController : MonoBehaviour
// ---------------- призрак ----------------
// Перенос построенного здания: призрак на его месте, "Сюда" -- переезжает бесплатно
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)
{
IsActive = true;
UpdateButtonLabel();
BuildGrid.Instance.ShowOverlay(true);
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
if (banner == null) banner = CreateBanner();
}
CloseChooser();
CancelGhost();
moving = building;
BuildGrid.IgnoredBuilding = building; // его клетки считаются свободными, пока выбираем новое место
BuildGrid.Instance.Refresh();
ghost = BuildGhost.Create(data);
ghost.MoveNear((Vector2)building.transform.position + ghost.Footprint.anchorOffset);
CreatePlacePanel();
}
void StartGhost(BuildingData data)
{
CancelGhost();
@@ -166,6 +210,12 @@ public class BuildModeController : MonoBehaviour
void CancelGhost()
{
StopDrag();
if (moving != null || BuildGrid.IgnoredBuilding != null)
{
moving = null;
BuildGrid.IgnoredBuilding = null; // здание остаётся на старом месте
if (BuildGrid.Instance != null) BuildGrid.Instance.Refresh();
}
if (ghost != null) Destroy(ghost.gameObject);
ghost = null;
if (placePanel != null) Destroy(placePanel);
@@ -178,11 +228,28 @@ public class BuildModeController : MonoBehaviour
var d = ghost.Data;
ghost.Recheck();
if (!ghost.CellsFree) { UIKit.Toast("Здесь строить нельзя: клетки заняты"); return; }
if (moving != null)
{
var building = moving;
var id = building != null ? building.GetComponent<PlacedBuildingId>() : null;
if (id == null || string.IsNullOrEmpty(id.buildingName)) { CancelGhost(); return; } // здания уже нет
// Перенос бесплатный: меняется только место. Уровень, стройка и улучшение остаются у того же здания,
// ресурсы не тратятся и не возвращаются (решения 04.11)
building.transform.position = ghost.PrefabPosition;
CancelGhost(); // снимает "игнор" клеток -- здание уже на новом месте
UIKit.Toast("Перенесено: " + d.buildingName);
return;
}
string limit = LimitProblem(d);
if (limit != null) { UIKit.Toast(limit); return; }
if (BuildTimer.DurationMs(false, 1) > 0 && BuildTimer.FreeBuilders == 0) { UIKit.Toast("Все строители заняты"); return; }
if (!d.CanAfford()) { UIKit.Toast(GameProgress.Instance.MissingText(ToRewards(d.cost))); return; }
if (BuildManager.Instance.BuildAt(d, ghost.Cell) != null)
{
UIKit.Toast("Построено: " + d.buildingName);
UIKit.Toast(BuildTimer.DurationMs(false, 1) > 0 ? "Стройка началась: " + d.buildingName : "Построено: " + d.buildingName);
CancelGhost(); // режим остаётся: "Здания" в подсказке сверху -- строить дальше
}
else UIKit.Toast("Не получилось построить");
@@ -202,8 +269,8 @@ public class BuildModeController : MonoBehaviour
placeInfo = UIKit.Text(bar, "", 26, Color.white, TextAlignmentOptions.Left);
UIKit.Size(placeInfo, -1, -1, 1);
placeButton = UIKit.Button(bar, "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
placeButton = UIKit.Button(bar, moving != null ? "Сюда" : "Поставить", UIKit.Theme.Green, Place, 230, 90, 28);
if (moving == null) UIKit.Button(bar, "Другое", UIKit.Theme.Blue, OpenChooser, 180, 90, 26);
UIKit.Button(bar, "Отмена", UIKit.Theme.Gray, CancelGhost, 180, 90, 26);
RefreshPlacePanel();
}
@@ -212,12 +279,22 @@ public class BuildModeController : MonoBehaviour
{
if (ghost == null || placeInfo == null) return;
var d = ghost.Data;
bool afford = d.CanAfford();
string where = ghost.CellsFree ? "<color=#9be59b>место свободно</color>" : "<color=#ff7a7a>клетки заняты</color>";
if (moving != null)
{
placeInfo.text = "<b>Перенос: " + d.buildingName + "</b> " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y +
" кл. • " + where + "\nБесплатно";
UIKit.SetButton(placeButton, ghost.CellsFree ? "Сюда" : "Занято", ghost.CellsFree);
return;
}
bool afford = d.CanAfford();
string limit = LimitProblem(d);
bool builders = BuildTimer.DurationMs(false, 1) <= 0 || BuildTimer.FreeBuilders > 0;
placeInfo.text = "<b>" + d.buildingName + "</b> " + ghost.Footprint.size.x + "×" + ghost.Footprint.size.y +
" кл. • " + where + "\n" + CostText(d);
bool ok = ghost.CellsFree && afford;
UIKit.SetButton(placeButton, ok ? "Поставить" : (ghost.CellsFree ? "Мало ресурсов" : "Занято"), ok);
" кл. • " + where + "\n" + CostText(d) + BuildTimeText();
bool ok = ghost.CellsFree && afford && limit == null && builders;
string label = ok ? "Поставить" : !ghost.CellsFree ? "Занято" : limit != null ? "Лимит" : !builders ? "Строители заняты" : "Мало ресурсов";
UIKit.SetButton(placeButton, label, ok);
}
// ---------------- окно выбора здания ----------------
@@ -239,7 +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, "Ратуша " + th + " ур. • выберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UIKit.Text(card, "Ратуша " + th + " ур. • строителей свободно: " + BuildTimer.FreeBuilders + " из " + BuildTimer.Builders +
"\nвыберите здание, потом место на сетке", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
var holder = UIKit.Make("ListHolder", card);
UIKit.Size(holder, -1, -1).flexibleHeight = 1;
@@ -254,7 +332,10 @@ public class BuildModeController : MonoBehaviour
foreach (var data in all)
{
var d = data;
bool locked = d.requiredTownHallLevel > th;
int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th);
int have = CountBuilt(d.buildingName);
bool full = max >= 0 && have >= max;
bool locked = d.requiredTownHallLevel > th || full;
bool afford = !locked && d.CanAfford();
var size = BuildFootprint.Of(d).size;
@@ -265,13 +346,15 @@ public class BuildModeController : MonoBehaviour
var info = UIKit.Card(row, new Color(0, 0, 0, 0), 0, 4);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>", 30,
string count = max >= 0 ? " <size=22><color=#aab>построено " + have + " / " + max + "</color></size>" : "";
UIKit.Text(info, d.buildingName + " <size=22><color=#aab>" + size.x + "×" + size.y + " кл.</color></size>" + count, 30,
locked ? UIKit.Theme.TextDim : UIKit.Theme.Text).fontStyle = FontStyles.Bold;
UIKit.Text(info, locked ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня" : CostText(d), 24,
locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
string why = d.requiredTownHallLevel > th ? "Откроется на Ратуше " + d.requiredTownHallLevel + " уровня"
: full ? NextLimitText(d.buildingName, th, have) : CostText(d);
UIKit.Text(info, why, 24, locked ? UIKit.Theme.Gold : UIKit.Theme.TextDim);
// выбрать можно и без ресурсов: место присмотреть, а "Поставить" загорится, когда хватит
string label = locked ? "Закрыто" : "Выбрать";
string label = d.requiredTownHallLevel > th ? "Закрыто" : full ? "Лимит" : "Выбрать";
var b = UIKit.Button(row, label, afford ? UIKit.Theme.Green : UIKit.Theme.Gray, () => StartGhost(d), 230, 90, 26);
b.interactable = !locked;
}
@@ -279,6 +362,43 @@ public class BuildModeController : MonoBehaviour
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, CloseChooser, -1, 90, 30);
}
// ---------------- лимит зданий по Ратуше (EconomyConfig, ТЕСТ) ----------------
static int CountBuilt(string buildingName)
{
int n = 0;
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
if (id.buildingName == buildingName) n++;
return n;
}
// null -- лимит позволяет; иначе текст для игрока
static string LimitProblem(BuildingData d)
{
int th = TownHall.Instance != null ? TownHall.Instance.currentLevel : 1;
int max = EconomyConfig.Load().BuildingLimitFor(d.buildingName, th);
int have = CountBuilt(d.buildingName);
if (max < 0 || have < max) return null;
return NextLimitText(d.buildingName, th, have);
}
static string NextLimitText(string buildingName, int th, int have)
{
var config = EconomyConfig.Load();
for (int lvl = th + 1; lvl <= 10; lvl++)
if (config.BuildingLimitFor(buildingName, lvl) > have)
return "Лимит " + have + " / " + config.BuildingLimitFor(buildingName, th) + ": больше — на Ратуше " + lvl + " ур.";
return "Достигнут лимит: " + have;
}
static string BuildTimeText()
{
long ms = BuildTimer.DurationMs(false, 1);
if (ms <= 0) return "";
string free = BuildTimer.FreeBuilders > 0 ? "" : " <color=#ff7a7a>все строители заняты</color>";
return " • стройка " + GameData.FormatTime(ms / 1000.0) + free;
}
void CloseChooser()
{
if (chooser != null) Destroy(chooser);
+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:
+44 -47
View File
@@ -658,85 +658,82 @@ public class GameProgress : MonoBehaviour
return string.Join(", ", parts.ToArray());
}
// ОЧЕРЕДЬ ОБУЧЕНИЯ ведёт модуль Армии (решения по 8 вопросам Саши, 31.10, п. 8). Здесь -- только то, что знает
// слой приложения: есть ли Казармы и открыт ли тип войск, цена (Экономика), длина очереди от числа Казарм, окна.
// Время -- часы игры (GameClock.UnixNowMs): перемотка DEV и офлайн досчитывают обучение так же, как марши.
public IReadOnlyList<TrainingOrderInfo> Training => Army.Training;
public void Train(int index, int count)
{
var def = GameData.Troops[index];
if (Barracks.Count == 0) { UIKit.Toast("Сначала постройте Казармы"); return; }
if (!TroopUnlocked(index)) { UIKit.Toast("Нужно: " + TroopLockText(index)); return; }
if (Data.training.Count >= Barracks.QueueSize) { UIKit.Toast("Очередь обучения заполнена. Больше казарм -- длиннее очередь"); return; }
// Воин -- житель города (модуль Населения): берутся свободные жители, резерв не отдаётся
var population = Population;
if (population != null && population.Recruitable < count)
// Очередь и жители (резерв не отдаётся) -- проверяет Армия вместе с Населением
if (!Army.CanQueueTraining(index, count, Barracks.QueueSize, out string error)) { UIKit.Toast(error); return; }
if (!Pay(def.cost, count)) return;
long durationMs = (long)(def.secondsPerUnit * 1000.0 * count);
if (!Army.TryQueueTraining(index, count, durationMs, Barracks.QueueSize, GameClock.UnixNowMs, out error))
{
UIKit.Toast(population.Recruitable > 0
? "Жителей хватит только на " + population.Recruitable + " воинов"
: "Некого нанять: свободных жителей нет (резерв " + population.CivilianReserve + "). Снимите рабочих или стройте Дома");
// Заказ не встал -- цену возвращаем целиком
var refund = new RewardItem[def.cost.Length];
for (int i = 0; i < def.cost.Length; i++) refund[i] = new RewardItem(def.cost[i].type, def.cost[i].amount * count);
Grant(refund, 0, false);
UIKit.Toast(error);
return;
}
if (!Pay(def.cost, count)) return;
bool recruited = population != null && population.TryRecruit(count);
long start = Data.training.Count > 0 ? Data.training[Data.training.Count - 1].endTicks : Now;
if (start < Now) start = Now;
long end = start + TimeSpan.FromSeconds(def.secondsPerUnit * count).Ticks;
Data.training.Add(new TrainingOrder { troopIndex = index, count = count, startTicks = start, endTicks = end, recruited = recruited });
UIKit.Toast("Обучение начато: " + count + " × " + def.name);
MarkChanged();
}
public double TrainingSecondsLeft(TrainingOrder o) { return TimeSpan.FromTicks(Math.Max(0, o.endTicks - Now)).TotalSeconds; }
public double TrainingSecondsLeft(TrainingOrderInfo o) { return Math.Max(0, o.EndMs - GameClock.UnixNowMs) / 1000.0; }
public float TrainingProgress(TrainingOrder o)
public float TrainingProgress(TrainingOrderInfo o)
{
long total = o.endTicks - o.startTicks;
long total = o.EndMs - o.StartMs;
if (total <= 0) return 1f;
return Mathf.Clamp01((float)(Now - o.startTicks) / total);
return Mathf.Clamp01((float)(GameClock.UnixNowMs - o.StartMs) / total);
}
public int SpeedUpCost
{
get
{
if (Data.training.Count == 0) return 0;
double secs = TrainingSecondsLeft(Data.training[Data.training.Count - 1]);
var queue = Training;
if (queue.Count == 0) return 0;
double secs = TrainingSecondsLeft(queue[queue.Count - 1]);
return Mathf.Max(1, Mathf.CeilToInt((float)secs / 30f)); // 1 кристалл за 30 сек
}
}
public void SpeedUpTraining()
{
if (Data.training.Count == 0) return;
if (Training.Count == 0) return;
if (!Pay(new[] { new RewardItem(ResourceType.Gems, SpeedUpCost) })) return;
foreach (var o in Data.training) o.endTicks = Now;
ProcessTraining();
Army.FinishAllTraining(GameClock.UnixNowMs);
}
// Раз в секунду: Армия заканчивает заказы обучения и лечение раненых, срок которых наступил (и пока игра была закрыта)
void ProcessTraining()
{
bool any = false;
while (Data.training.Count > 0 && Data.training[0].endTicks <= Now)
{
var o = Data.training[0];
Data.training.RemoveAt(0);
if (o.recruited && Population != null)
{
// Люди из Казарм переходят в Армию одним действием: не теряются и не дублируются
if (!Population.CompleteTraining(o.troopIndex, o.count))
{
Debug.LogWarning($"[ВОЙСКА] Армия не приняла {o.count} × {GameData.Troops[o.troopIndex].name} -- новобранцы остаются в Казармах");
continue; // воинов не прибавилось -- и в статистику не идёт
}
}
else Army.AddTroops(o.troopIndex, o.count); // старый заказ: людей под него не брали
Data.troopsTrained += o.count;
Data.dailyTroops += o.count;
Data.eventTroopRemainder += o.count;
AddEventPoints(Data.eventTroopRemainder / 5);
Data.eventTroopRemainder %= 5;
UIKit.Toast("Обучено: " + o.count + " × " + GameData.Troops[o.troopIndex].name);
any = true;
}
if (any) MarkChanged();
if (!GameServices.TryGet(out IArmy army)) return;
long now = GameClock.UnixNowMs;
army.ProcessTraining(now);
army.ProcessHealing(now);
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();
}
// Потери в бою: убираем процент от каждого типа войск
+1 -1
View File
@@ -97,7 +97,7 @@ public class ProgressData
// из старых сохранений: при первой загрузке его читает App/GameModules, потом оно обнуляется.
public int[] troops = new int[4];
public bool armyInModule; // войска уже перенесены в модуль Армии
public List<TrainingOrder> training = new List<TrainingOrder>();
public List<TrainingOrder> training = new List<TrainingOrder>(); // УСТАРЕЛО: очередь у Армии (save.json, "army"); только для переноса старых сохранений
// --- Бой (кампания) ---
public int battleStage = 1;
+199 -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;
@@ -31,6 +49,27 @@ public class TroopsPanel : BasePanel
ProfilePanel.KV(army, "Можно нанять из жителей", population.Recruitable.ToString("N0") +
" (резерв " + population.CivilianReserve.ToString("N0") + ")");
}
// Раненые (решения 31.10, п. 7): лечатся в госпитале или ждут места; не воюют, но считаются людьми
IArmy hurt = GameServices.Get<IArmy>();
if (hurt.WoundedTotal > 0)
{
int waiting = 0, healingNow = 0;
long readyMs = long.MaxValue;
for (int t = 0; t < hurt.TypeCount; t++) waiting += hurt.GetWoundedWaiting(t);
foreach (var h in hurt.Healing) { healingNow += h.Count; readyMs = System.Math.Min(readyMs, h.EndMs); }
string next = readyMs != long.MaxValue
? ", ближайшие вылечатся через " + GameData.FormatTime(System.Math.Max(0, readyMs - GameClock.UnixNowMs) / 1000.0)
: "";
ProfilePanel.KV(army, "Раненые", hurt.WoundedTotal.ToString("N0") + " (лечатся " + healingNow + ", ждут места " + waiting + ")");
ProfilePanel.KV(army, "Госпиталь", "мест " + hurt.HospitalBeds + next);
}
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);
@@ -51,31 +90,45 @@ public class TroopsPanel : BasePanel
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);
@@ -86,37 +139,133 @@ public class TroopsPanel : BasePanel
}
Tick();
// --- Типы войск ---
for (int i = 0; i < GameData.Troops.Count; i++)
// --- Типы войск: карточки в ряд, листаются вбок ---
var troopsCard = UIKit.Card(list, UIKit.Theme.CardDark);
ProfilePanel.Header(troopsCard, "Найм войск");
UIKit.Text(troopsCard, "Листайте карточки вбок →", 22, UIKit.Theme.TextDim);
var strip = HorizontalStrip(troopsCard, CardHeight);
for (int i = 0; i < GameData.Troops.Count; i++) TroopCard(strip, i);
}
// ---------------- Карточка войска ----------------
// Место под иконку: пока квадрат цвета войска с первой буквой. Картинку поставим в TroopIcon, когда будет арт.
// "Силён / слаб против" берётся из правил боя (контры в модуле Боя и BattleConfig), а не пишется руками:
// поменяют контры -- поменяется и текст.
const float CardWidth = 340f;
const float CardHeight = 640f;
void TroopCard(Transform strip, int i)
{
int idx = i;
var def = GameData.Troops[i];
bool unlocked = GP.TroopUnlocked(i);
var card = UIKit.Card(strip, unlocked ? UIKit.Theme.Card : UIKit.Theme.CardDark, 16, 8);
UIKit.Size(card, CardWidth, -1);
var iconRow = UIKit.Row(card, 130, 0);
var lpad = UIKit.Make("Pad", iconRow); UIKit.Size(lpad, -1, -1, 1);
TroopIcon(iconRow, i, unlocked);
var rpad = UIKit.Make("Pad", iconRow); UIKit.Size(rpad, -1, -1, 1);
UIKit.Text(card, def.name, 32, UIKit.Theme.Text, TextAlignmentOptions.Center).fontStyle = FontStyles.Bold;
UIKit.Text(card, "в армии: " + GP.TroopCount(i).ToString("N0"), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
UIKit.Text(card, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин", 22, UIKit.Theme.Text, TextAlignmentOptions.Center);
UIKit.Text(card, GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
string strong, weak;
CounterText(i, out strong, out weak);
if (strong.Length > 0) UIKit.Text(card, "<color=#9be59b>Силён против:</color> " + strong, 22, UIKit.Theme.Text);
if (weak.Length > 0) UIKit.Text(card, "<color=#ff8a80>Слаб против:</color> " + weak, 22, UIKit.Theme.Text);
UIKit.Text(card, def.description, 20, UIKit.Theme.TextDim);
var fill = UIKit.Make("Fill", card); UIKit.Size(fill, -1, -1).flexibleHeight = 1;
if (!unlocked)
{
int idx = i;
var def = GameData.Troops[i];
bool unlocked = GP.TroopUnlocked(i);
var card = UIKit.Card(list, unlocked ? UIKit.Theme.Card : UIKit.Theme.CardDark);
var top = UIKit.Row(card, 120, 16);
UIKit.Icon(top, unlocked ? def.color : UIKit.Theme.Gray, def.name.Substring(0, 1), 110);
var info = UIKit.Card(top, new Color(0, 0, 0, 0), 0, 2);
UIKit.Size(info, -1, -1, 1);
UIKit.Text(info, def.name + " <color=#c8c8c8><size=24>в армии: " + GP.TroopCount(i).ToString("N0") + "</size></color>", 32).fontStyle = FontStyles.Bold;
UIKit.Text(info, def.description, 22, UIKit.Theme.TextDim);
UIKit.Text(info, "Сила " + def.power + " • " + def.secondsPerUnit + " сек/воин • " + GameData.RewardsText(def.cost), 22, UIKit.Theme.TextDim);
if (!unlocked)
{
UIKit.Text(card, "Откроется: " + GP.TroopLockText(i), 26, UIKit.Theme.Gold, TextAlignmentOptions.Center);
continue;
}
var btns = UIKit.Row(card, 80, 10);
foreach (int amount in Amounts)
{
int a = amount;
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && d.training.Count < Barracks.QueueSize && GP.Recruitable >= a;
var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 80, 30);
UIKit.Size(b, -1, 80, 1);
}
UIKit.Text(card, "Откроется:\n" + GP.TroopLockText(i), 24, UIKit.Theme.Gold, TextAlignmentOptions.Center);
return;
}
var btns = UIKit.Row(card, 70, 8);
foreach (int amount in Amounts)
{
int a = amount;
bool can = Barracks.Count > 0 && GP.CanAfford(def.cost, a) && GP.Training.Count < Barracks.QueueSize && GP.Recruitable >= a;
var b = UIKit.Button(btns, "+" + a, can ? UIKit.Theme.Green : UIKit.Theme.Gray, () => GP.Train(idx, a), -1, 70, 26);
UIKit.Size(b, -1, 70, 1);
}
}
// Иконка войска. Сейчас -- заглушка (цветной квадрат с буквой); сюда же потом подставим спрайт.
static void TroopIcon(Transform parent, int i, bool unlocked)
{
var def = GameData.Troops[i];
UIKit.Icon(parent, unlocked ? def.color : UIKit.Theme.Gray, def.name.Substring(0, 1), 120);
}
static readonly string[] TroopNamesGenitive = { "пехоты", "лучников", "кавалерии", "осадных" };
// Контры из правил боя: против кого этот тип сильнее и кто сильнее против него
static void CounterText(int type, out string strong, out string weak)
{
var s = new List<string>();
var w = new List<string>();
var battle = GameServices.TryGet(out IBattleSystem b) ? b as BattleSystem : null;
if (battle != null)
for (int j = 0; j < TroopType.Count && j < GameData.Troops.Count; j++)
{
if (j == type) continue;
int mine = battle.Counter(type, j), theirs = battle.Counter(j, type);
if (mine > 0) s.Add(Genitive(j) + " (+" + mine + "% силы)");
if (theirs > 0) w.Add(Genitive(j));
}
if (type == TroopType.Siege)
{
var cfg = BattleConfig.Load();
if (cfg.siegeVsWallsPercent > 0) s.Add("стен при штурме (+" + cfg.siegeVsWallsPercent + "%)");
if (cfg.siegeInFieldPercent < 0) w.Add("всех в поле и в обороне (" + cfg.siegeInFieldPercent + "%)");
}
strong = string.Join(", ", s.ToArray());
weak = string.Join(", ", w.ToArray());
}
static string Genitive(int type)
{
return type >= 0 && type < TroopNamesGenitive.Length ? TroopNamesGenitive[type] : GameData.Troops[type].name;
}
// Горизонтальная лента с прокруткой вбок внутри общего вертикального списка.
// Вертикальный свайп по ленте прокручивает весь список (NestedScrollRect).
static RectTransform HorizontalStrip(Transform parent, float height)
{
var root = UIKit.Make("TroopStrip", parent);
UIKit.Size(root, -1, height);
root.gameObject.AddComponent<Image>().color = new Color(0, 0, 0, 0);
var scroll = root.gameObject.AddComponent<NestedScrollRect>();
scroll.horizontal = true;
scroll.vertical = false;
scroll.movementType = ScrollRect.MovementType.Elastic;
scroll.scrollSensitivity = 40f;
var viewport = UIKit.Stretch(UIKit.Make("Viewport", root));
viewport.gameObject.AddComponent<RectMask2D>();
var content = UIKit.Make("Content", viewport);
content.anchorMin = new Vector2(0, 0); content.anchorMax = new Vector2(0, 1);
content.pivot = new Vector2(0, 0.5f);
content.offsetMin = Vector2.zero; content.offsetMax = Vector2.zero;
var h = content.gameObject.AddComponent<HorizontalLayoutGroup>();
h.spacing = 14;
h.padding = new RectOffset(4, 4, 4, 4);
h.childControlWidth = true; h.childControlHeight = true;
h.childForceExpandWidth = false; h.childForceExpandHeight = true;
content.gameObject.AddComponent<ContentSizeFitter>().horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
scroll.viewport = viewport;
scroll.content = content;
return content;
}
// Окно перестраивается само: Армия публикует TroopsChangedEvent, GameProgress сообщает об изменении
@@ -130,14 +279,20 @@ public class TroopsPanel : BasePanel
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);
}
}
@@ -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
+562 -3
View File
@@ -9,6 +9,38 @@ using System.Text;
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
// Гарнизон (garrison) -- часть воинов города: они в troops, но в поход не уходят. garrison[i] <= troops[i] всегда.
// Очередь обучения (training) -- тоже здесь (решения по 8 вопросам Саши, 31.10, п. 8): заказы, сроки, завершение.
// Раненые (решения 31.10, п. 7): ждут места (woundedWaiting) или лечатся (healing) -- не в troops и не в походах.
// Числа госпиталя (регламент 31.10, ТЕСТ). В игре -- из таблицы HospitalConfig.
public sealed class HospitalRules
{
public int TemporaryBeds = 5; // мест, пока здания госпиталя нет (решения 01.11, п. 3); после постройки -- штатные, не вместе
public int[] HealGameHours = { 4, 4, 6, 8 }; // лечение одного воина: пехота и лучники 4, кавалерия 6, осадные 8 ИГРОВЫХ часов
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
// Цена лечения одного воина по типам (решения 01.11, п. 3: 1 / 1 / 2 / 3 золота -- в таблице HospitalConfig); пусто -- без цены
public ResourceAmount[][] HealCostPerSoldier = null;
public static HospitalRules Default => new HospitalRules();
public long HealMs(int type)
{
int hours = HealGameHours != null && type >= 0 && type < HealGameHours.Length ? Math.Max(0, HealGameHours[type]) : 4;
return hours * GameHourMs;
}
}
// Пропавшие без вести (решения 01.11, п. 5). Доля возвращающихся и формула -- НЕ УТВЕРЖДЕНЫ (ТЕСТ, таблица HospitalConfig)
public sealed class MissingRules
{
public int ReturnPercent = 50; // сколько пропавших вернётся (утверждён диапазон 10-90%, формула открыта)
public int WoundedPercent = 30; // из вернувшихся -- раненые (утверждено: 70% здоровыми, 30% ранеными)
public int WindowGameHours = 6; // окно возвращения после обратного пути, ИГРОВЫХ часов
public long GameHourMs = 5 * 60 * 1000;
public int MaxGroups = 4; // возвращаются 2-4 группами (меньше, если пропавших мало)
public static MissingRules Default => new MissingRules();
}
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
@@ -23,6 +55,51 @@ public sealed class ArmyService : IArmy, ISaveParticipant
private readonly int[] garrison = new int[Types];
private readonly List<MarchUnits> marches = new List<MarchUnits>();
private class TrainingOrder
{
public int id, type, count;
public long startMs, endMs;
public bool recruited; // под заказ взяты жители (Население). false -- старый заказ, людей не брали
}
private class HealingBatch
{
public int id, type, count;
public long startMs, endMs;
}
private readonly int[] woundedWaiting = new int[Types];
private readonly List<HealingBatch> healing = new List<HealingBatch>();
private readonly List<HealingInfo> healingView = new List<HealingInfo>();
private int nextHealId = 1;
private readonly HospitalRules hospital;
// Пропавшие без вести одного боя: группы возвращения рассчитаны сразу, при постановке (детерминированно)
private class MissingEvent
{
public int id, marchId;
public long windowStartMs, endMs;
public int[] remaining = new int[Types]; // ещё не вернулись и не списаны
public List<MissingGroup> groups = new List<MissingGroup>();
}
private class MissingGroup
{
public int index;
public long timeMs;
public int[] healthy = new int[Types], wounded = new int[Types];
}
private readonly List<MissingEvent> missing = new List<MissingEvent>();
private readonly List<MissingInfo> missingView = new List<MissingInfo>();
private readonly HashSet<int> missingMarches = new HashSet<int>(); // по каким походам пропавшие уже отмечены
private int nextMissingId = 1;
private readonly MissingRules missingRules;
private readonly List<TrainingOrder> training = new List<TrainingOrder>();
private readonly List<TrainingOrderInfo> trainingView = new List<TrainingOrderInfo>();
private int nextOrderId = 1;
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
public bool RestoredFromSave { get; private set; }
@@ -30,6 +107,21 @@ public sealed class ArmyService : IArmy, ISaveParticipant
public int TypeCount => Types;
// Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже.
private readonly int baseMarchSlots;
public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null, MissingRules missingRules = null)
{
this.baseMarchSlots = Math.Max(1, baseMarchSlots);
this.hospital = hospital ?? HospitalRules.Default;
this.missingRules = missingRules ?? MissingRules.Default;
}
public HospitalRules Hospital => hospital;
public int MarchSlots => baseMarchSlots;
public int ActiveMarches => marches.Count;
public int GetTroops(int type)
{
return type >= 0 && type < Types ? troops[type] : 0;
@@ -100,6 +192,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
{
// Поход с таким номером уже есть: перезапись потеряла бы его воинов
if (marches.Exists(m => m.marchId == marchId)) return false;
// Все слоты похода заняты. Походы из старых сохранений сверх лимита дойдут, но новых не будет
if (marches.Count >= MarchSlots) return false;
// В поход -- только свободные воины, гарнизон остаётся в городе
var free = new int[Types];
@@ -139,17 +233,358 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
// Правила те же, что у ReturnFromMarch: только известный поход, только один раз.
public bool ReturnUnits(int marchId, int[] survivorUnits)
{
return ReturnUnits(marchId, survivorUnits, null, 0);
}
// Выжившие -- в город, раненые -- в госпиталь (решения 31.10, п. 7). Остальные из ушедших погибли.
// Один солдат в одном месте: выжившие + раненые не больше, чем ушло, по каждому типу.
public bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++)
troops[i] += Math.Max(0, Math.Min(survivorUnits[i], entry.units[i]));
bool anyWounded = false;
if (woundedUnits != null) ProcessHealing(nowMs); // сначала освободить места, которые освободились раньше
for (int i = 0; i < Types; i++)
{
int alive = survivorUnits != null && i < survivorUnits.Length ? Math.Max(0, Math.Min(survivorUnits[i], entry.units[i])) : 0;
int hurt = woundedUnits != null && i < woundedUnits.Length ? Math.Max(0, Math.Min(woundedUnits[i], entry.units[i] - alive)) : 0;
troops[i] += alive;
woundedWaiting[i] += hurt;
if (hurt > 0) anyWounded = true;
}
marches.Remove(entry);
if (anyWounded) FillBeds(nowMs);
Changed();
return true;
}
// ---------------- Раненые и лечение ----------------
public int WoundedTotal
{
get
{
int n = 0;
foreach (var w in woundedWaiting) n += w;
foreach (var b in healing) n += b.count;
return n;
}
}
public int GetAllTroops(int type)
{
if (type < 0 || type >= Types) return 0;
int n = troops[type] + woundedWaiting[type];
foreach (var m in marches) n += m.units[type];
foreach (var b in healing) if (b.type == type) n += b.count;
return n;
}
// ---------------- Пропавшие без вести ----------------
public int MissingTotal
{
get { int n = 0; foreach (var e in missing) foreach (var r in e.remaining) n += r; return n; }
}
public IReadOnlyList<MissingInfo> Missing
{
get
{
missingView.Clear();
foreach (var e in missing)
{
int n = 0;
foreach (var r in e.remaining) n += r;
missingView.Add(new MissingInfo { Id = e.id, Count = n, WindowStartMs = e.windowStartMs, EndMs = e.endMs });
}
return missingView;
}
}
public bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs)
{
if (missingUnits == null || missingMarches.Contains(marchId)) return false;
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null) return false;
var e = new MissingEvent { id = nextMissingId++, marchId = marchId };
int total = 0;
for (int i = 0; i < Types && i < missingUnits.Length; i++)
{
int n = Math.Max(0, Math.Min(missingUnits[i], entry.units[i]));
entry.units[i] -= n; // из похода -- в пропавшие: один солдат в одном месте
e.remaining[i] = n;
total += n;
}
if (total == 0) { nextMissingId--; return false; }
e.windowStartMs = battleMs + Math.Max(0, travelMs);
e.endMs = e.windowStartMs + Math.Max(0, missingRules.WindowGameHours) * missingRules.GameHourMs;
PlanGroups(e, battleMs);
missing.Add(e);
missingMarches.Add(marchId);
Changed();
return true;
}
// Кто вернётся и когда: доля возвращающихся по типам (дробная часть -- бросок), 2-4 группы в окне,
// в каждой -- 70% здоровых, 30% раненых (дробная часть -- бросок). Тип войск сохраняется.
void PlanGroups(MissingEvent e, long seedTime)
{
ulong seed = (ulong)e.marchId * 0x9E3779B97F4A7C15UL ^ (ulong)seedTime;
int roll = 0;
bool Chance(long num, long den) => den > 0 && (long)(Next(seed, roll++) % (ulong)den) < num;
var back = new int[Types];
int returning = 0;
for (int i = 0; i < Types; i++)
{
long num = (long)e.remaining[i] * Math.Max(0, Math.Min(100, missingRules.ReturnPercent));
back[i] = (int)(num / 100) + (Chance(num % 100, 100) ? 1 : 0);
returning += back[i];
}
if (returning == 0) return;
int groups = Math.Max(1, Math.Min(Math.Max(2, missingRules.MaxGroups), returning)); // мало вернувшихся -- групп меньше
if (returning >= 2) groups = Math.Max(2, Math.Min(groups, 2 + (int)(Next(seed, roll++) % (ulong)(Math.Max(2, missingRules.MaxGroups) - 1))));
long window = Math.Max(1, e.endMs - e.windowStartMs);
for (int g = 0; g < groups; g++)
e.groups.Add(new MissingGroup { index = g + 1, timeMs = e.windowStartMs + window * (g + 1) / (groups + 1) });
// Раздаём вернувшихся по группам по очереди, внутри группы -- здоровые и раненые
for (int i = 0; i < Types; i++)
for (int k = 0; k < back[i]; k++)
{
var grp = e.groups[(k + i) % groups];
if (Chance(Math.Max(0, Math.Min(100, missingRules.WoundedPercent)), 100)) grp.wounded[i]++;
else grp.healthy[i]++;
}
}
static ulong Next(ulong seed, int n)
{
ulong x = seed + 0x9E3779B97F4A7C15UL * (ulong)(n + 1);
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
public int ProcessMissing(long nowMs)
{
int done = 0;
bool changed = false;
for (int k = missing.Count - 1; k >= 0; k--)
{
var e = missing[k];
e.groups.Sort((a, b) => a.timeMs.CompareTo(b.timeMs));
while (e.groups.Count > 0 && e.groups[0].timeMs <= nowMs && e.groups[0].timeMs <= e.endMs)
{
var g = e.groups[0];
e.groups.RemoveAt(0); // группа возвращается один раз
int healthy = 0, wounded = 0;
bool anyWounded = false;
for (int i = 0; i < Types; i++)
{
int h = Math.Min(g.healthy[i], e.remaining[i]);
int w = Math.Min(g.wounded[i], e.remaining[i] - h);
e.remaining[i] -= h + w;
troops[i] += h; // исходной армии может уже не быть -- в резерв поселения
woundedWaiting[i] += w; // раненые -- в госпиталь по обычным правилам
healthy += h; wounded += w;
if (w > 0) anyWounded = true;
}
if (anyWounded) { ProcessHealing(g.timeMs); FillBeds(g.timeMs); }
done++;
changed = true;
EventBus.Publish(new MissingReturnedEvent { MissingId = e.id, Group = g.index, Healthy = healthy, Wounded = wounded, TimeMs = g.timeMs });
}
if (nowMs >= e.endMs)
{
int left = 0;
foreach (var r in e.remaining) left += r;
missing.RemoveAt(k); // окно кончилось: кто не вернулся -- списан, места освобождены
changed = true;
EventBus.Publish(new MissingWrittenOffEvent { MissingId = e.id, Count = left, TimeMs = e.endMs });
}
}
if (changed) Changed();
return done;
}
public int GetWoundedWaiting(int type)
{
return type >= 0 && type < Types ? woundedWaiting[type] : 0;
}
public IReadOnlyList<HealingInfo> Healing
{
get
{
healingView.Clear();
foreach (var b in healing)
healingView.Add(new HealingInfo { Id = b.id, Type = b.type, Count = b.count, StartMs = b.startMs, EndMs = b.endMs });
return healingView;
}
}
// Места: от здания госпиталя (Поселение), пока его нет -- временные из таблицы
public int HospitalBeds => GameServices.TryGet(out IHospital h) && h.Beds > 0 ? h.Beds : Math.Max(0, hospital.TemporaryBeds);
public int ProcessHealing(long nowMs)
{
int done = 0;
// По времени: самая ранняя группа заканчивает -- на её место в тот же момент ложатся ждущие
while (true)
{
HealingBatch next = null;
foreach (var b in healing)
if (b.endMs <= nowMs && (next == null || b.endMs < next.endMs || b.endMs == next.endMs && b.id < next.id)) next = b;
if (next == null) break;
healing.Remove(next); // группа вылечена один раз: повторный расчёт её уже не найдёт
troops[next.type] += next.count;
done++;
EventBus.Publish(new SoldiersHealedEvent { BatchId = next.id, Type = next.type, Count = next.count, TimeMs = next.endMs });
FillBeds(next.endMs);
}
FillBeds(nowMs); // мест могло стать больше (госпиталь улучшили)
if (done > 0) Changed();
return done;
}
// Положить ждущих на свободные места в момент atMs: по типам по порядку, каждая группа -- свой срок лечения.
// Цена лечения (если задана) списывается через Экономику целиком; не хватает -- группа ждёт.
void FillBeds(long atMs)
{
int free = HospitalBeds;
foreach (var b in healing) free -= b.count;
for (int type = 0; type < Types && free > 0; type++)
{
int n = Math.Min(free, woundedWaiting[type]);
if (n <= 0) continue;
if (!PayForHealing(type, n)) continue;
woundedWaiting[type] -= n;
free -= n;
healing.Add(new HealingBatch { id = nextHealId++, type = type, count = n, startMs = atMs, endMs = atMs + hospital.HealMs(type) });
}
}
bool PayForHealing(int type, int soldiers)
{
var perSoldier = hospital.HealCostPerSoldier != null && type < hospital.HealCostPerSoldier.Length ? hospital.HealCostPerSoldier[type] : null;
if (perSoldier == null || perSoldier.Length == 0) return true; // цена не утверждена -- без цены
if (!GameServices.TryGet(out IEconomy economy)) return false;
var cost = new List<ResourceAmount>();
foreach (var c in perSoldier) if (c.Amount > 0) cost.Add(new ResourceAmount(c.Type, c.Amount * soldiers));
return cost.Count == 0 || economy.TrySpend(cost);
}
// ---------------- Очередь обучения ----------------
public IReadOnlyList<TrainingOrderInfo> Training
{
get
{
trainingView.Clear();
foreach (var o in training)
trainingView.Add(new TrainingOrderInfo { Id = o.id, Type = o.type, Count = o.count, StartMs = o.startMs, EndMs = o.endMs });
return trainingView;
}
}
public bool CanQueueTraining(int type, int count, int queueSize, out string error)
{
error = null;
if (type < 0 || type >= Types || count <= 0) error = "Неверный заказ";
else if (training.Count >= queueSize) error = "Очередь обучения заполнена. Больше казарм -- длиннее очередь";
else if (GameServices.TryGet(out IPopulation population) && population.Recruitable < count)
error = population.Recruitable > 0
? $"Жителей хватит только на {population.Recruitable} воинов"
: $"Некого нанять: свободных жителей нет (резерв {population.CivilianReserve}). Снимите рабочих или стройте Дома";
return error == null;
}
public bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error)
{
if (!CanQueueTraining(type, count, queueSize, out error)) return false;
// Воин -- житель города: люди уходят в Казармы сейчас, в Армию -- когда обучатся
bool recruited = false;
if (GameServices.TryGet(out IPopulation population))
{
if (!population.TryRecruit(count)) { error = "Некого нанять: жителей не хватает"; return false; }
recruited = true;
}
// Заказы идут друг за другом: новый начинается, когда закончится последний
long start = training.Count > 0 ? Math.Max(nowMs, training[training.Count - 1].endMs) : nowMs;
training.Add(new TrainingOrder
{
id = nextOrderId++, type = type, count = count,
startMs = start, endMs = start + Math.Max(0, durationMs), recruited = recruited
});
Changed();
return true;
}
public int ProcessTraining(long nowMs)
{
int done = 0;
while (training.Count > 0 && training[0].endMs <= nowMs)
{
TrainingOrder o = training[0];
if (o.recruited)
{
// Новобранцы переходят из Населения в Армию одним действием (Население само зовёт AddTroops).
// Населения ещё нет (запуск) -- подождём: заказ остаётся, никто не теряется.
if (!GameServices.TryGet(out IPopulation population)) break;
if (!population.CompleteTraining(o.type, o.count))
{
// Население не нашло этих новобранцев (испорченное сохранение): заказ снимается без воинов --
// иначе люди появились бы из ниоткуда, а очередь встала бы навсегда
training.RemoveAt(0);
Changed();
continue;
}
}
else AddTroops(o.type, o.count); // старый заказ: людей под него не брали
training.RemoveAt(0); // заказ закончен один раз: повторный расчёт его уже не найдёт
done++;
EventBus.Publish(new TroopsTrainedEvent { OrderId = o.id, Type = o.type, Count = o.count, TimeMs = o.endMs });
}
return done;
}
public int FinishAllTraining(long nowMs)
{
foreach (var o in training)
{
o.endMs = Math.Min(o.endMs, nowMs);
o.startMs = Math.Min(o.startMs, o.endMs);
}
return ProcessTraining(nowMs);
}
// Перенос очереди из старого progress.json (её вёл GameProgress). Время уже переведено в мс часов игры.
public void ImportLegacyTraining(IEnumerable<(int type, int count, long startMs, long endMs, bool recruited)> orders)
{
if (orders == null) return;
foreach (var o in orders)
{
if (o.type < 0 || o.type >= Types || o.count <= 0) continue;
training.Add(new TrainingOrder
{
id = nextOrderId++, type = o.type, count = o.count,
startMs = o.startMs, endMs = Math.Max(o.startMs, o.endMs), recruited = o.recruited
});
}
Changed();
}
void ClampGarrison()
{
for (int i = 0; i < Types; i++) garrison[i] = Math.Max(0, Math.Min(garrison[i], troops[i]));
@@ -157,7 +592,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal });
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal,
Missing = MissingTotal });
}
// ---------------- Перенос из старого progress.json ----------------
@@ -189,6 +625,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
// garrison=<пехота>,<лучники>,<кавалерия>,<осадные> (из troops; нет строки -- гарнизона нет)
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
// train=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>|<люди взяты 1/0> (по порядку очереди)
// trainNext=<номер следующего заказа>
// wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале)
// heal=<id>|<тип>|<сколько>|<начало, мс>|<конец, мс>
// healNext=<номер следующей группы лечения>
// missing=<id>|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,>
// mgroup=<id пропавших>|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы)
// missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер>
public string SaveKey => "army";
@@ -199,6 +643,34 @@ public sealed class ArmyService : IArmy, ISaveParticipant
sb.Append("garrison=").Append(Join(garrison)).Append('\n');
foreach (var m in marches)
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
foreach (var o in training)
sb.Append("train=").Append(string.Join("|", new[]
{
N(o.id), N(o.type), N(o.count),
o.startMs.ToString(CultureInfo.InvariantCulture), o.endMs.ToString(CultureInfo.InvariantCulture), o.recruited ? "1" : "0"
})).Append('\n');
sb.Append("trainNext=").Append(N(nextOrderId)).Append('\n');
sb.Append("wounded=").Append(Join(woundedWaiting)).Append('\n');
foreach (var b in healing)
sb.Append("heal=").Append(string.Join("|", new[]
{
N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture)
})).Append('\n');
sb.Append("healNext=").Append(N(nextHealId)).Append('\n');
foreach (var e in missing)
{
sb.Append("missing=").Append(string.Join("|", new[]
{
N(e.id), N(e.marchId), e.windowStartMs.ToString(CultureInfo.InvariantCulture), e.endMs.ToString(CultureInfo.InvariantCulture), Join(e.remaining)
})).Append('\n');
foreach (var g in e.groups)
sb.Append("mgroup=").Append(string.Join("|", new[]
{
N(e.id), N(g.index), g.timeMs.ToString(CultureInfo.InvariantCulture), Join(g.healthy), Join(g.wounded)
})).Append('\n');
}
foreach (var id in missingMarches) sb.Append("missingMarch=").Append(N(id)).Append('\n');
sb.Append("missingNext=").Append(N(nextMissingId)).Append('\n');
return sb.ToString();
}
@@ -207,6 +679,14 @@ public sealed class ArmyService : IArmy, ISaveParticipant
Array.Clear(troops, 0, Types);
Array.Clear(garrison, 0, Types);
marches.Clear();
training.Clear();
nextOrderId = 1;
Array.Clear(woundedWaiting, 0, Types);
healing.Clear();
nextHealId = 1;
missing.Clear();
missingMarches.Clear();
nextMissingId = 1;
RestoredFromSave = true;
if (!string.IsNullOrEmpty(data))
@@ -231,6 +711,75 @@ public sealed class ArmyService : IArmy, ISaveParticipant
&& !marches.Exists(x => x.marchId == id)) // один поход -- одна запись
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
}
else if (line.StartsWith("train="))
{
string[] p = line.Substring("train=".Length).Split('|');
if (p.Length == 6 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int oid)
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int type) && type >= 0 && type < Types
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) && count > 0
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long start)
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long end)
&& !training.Exists(x => x.id == oid)) // один заказ -- одна запись
{
training.Add(new TrainingOrder { id = oid, type = type, count = count, startMs = start, endMs = end, recruited = p[5] == "1" });
nextOrderId = Math.Max(nextOrderId, oid + 1);
}
}
else if (line.StartsWith("wounded="))
{
int[] w = Parse(line.Substring("wounded=".Length));
Array.Copy(w, woundedWaiting, Types);
}
else if (line.StartsWith("heal="))
{
string[] p = line.Substring("heal=".Length).Split('|');
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hid)
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int htype) && htype >= 0 && htype < Types
&& int.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int hcount) && hcount > 0
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hstart)
&& long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long hend)
&& !healing.Exists(x => x.id == hid))
{
healing.Add(new HealingBatch { id = hid, type = htype, count = hcount, startMs = hstart, endMs = hend });
nextHealId = Math.Max(nextHealId, hid + 1);
}
}
else if (line.StartsWith("missing="))
{
string[] p = line.Substring("missing=".Length).Split('|');
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mid)
&& int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mmarch)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mstart)
&& long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mend)
&& !missing.Exists(x => x.id == mid))
{
missing.Add(new MissingEvent { id = mid, marchId = mmarch, windowStartMs = mstart, endMs = mend, remaining = Parse(p[4]) });
nextMissingId = Math.Max(nextMissingId, mid + 1);
}
}
else if (line.StartsWith("mgroup="))
{
string[] p = line.Substring("mgroup=".Length).Split('|');
MissingEvent owner = null;
if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gid))
owner = missing.Find(x => x.id == gid); // строки missing= идут раньше своих групп
if (owner != null && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gidx)
&& long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long gtime)
&& !owner.groups.Exists(x => x.index == gidx))
owner.groups.Add(new MissingGroup { index = gidx, timeMs = gtime, healthy = Parse(p[3]), wounded = Parse(p[4]) });
}
else if (line.StartsWith("missingMarch=")
&& int.TryParse(line.Substring("missingMarch=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mm))
missingMarches.Add(mm);
else if (line.StartsWith("missingNext=")
&& int.TryParse(line.Substring("missingNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mnext))
nextMissingId = Math.Max(nextMissingId, mnext);
else if (line.StartsWith("healNext=")
&& int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext))
nextHealId = Math.Max(nextHealId, hnext);
else if (line.StartsWith("trainNext=")
&& int.TryParse(line.Substring("trainNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int next))
nextOrderId = Math.Max(nextOrderId, next);
}
}
ClampGarrison(); // гарнизон не больше войск, даже если файл правили руками
@@ -243,9 +792,19 @@ public sealed class ArmyService : IArmy, ISaveParticipant
Array.Clear(troops, 0, Types);
Array.Clear(garrison, 0, Types);
marches.Clear();
training.Clear();
nextOrderId = 1;
Array.Clear(woundedWaiting, 0, Types);
healing.Clear();
nextHealId = 1;
missing.Clear();
missingMarches.Clear();
nextMissingId = 1;
Changed();
}
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
static string Join(int[] values)
{
var parts = new string[values.Length];
+26 -2
View File
@@ -6,7 +6,10 @@
// итог боя запоминает версию, по которой посчитан.
public sealed class BattleRules
{
public int Version = 2; // 2 -- добавлены голод и усталость
public int Version = 8; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14);
// 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7);
// 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1);
// 8 -- пропавшие без вести (п. 5)
// Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения).
public int[] UnitPower = { 4, 5, 9, 16 };
@@ -15,9 +18,30 @@ public sealed class BattleRules
public int WallsPercent = 20; // защитникам за стенами
public int SiegeVsWallsPercent = 50; // осадным при штурме стен
public int SiegeInFieldPercent = -25; // осадным в поле и в обороне
public int LoserLossPercent = 50; // потери проигравшего (не окончательная формула)
public int RandomPercent = 5; // случайность каждой стороны: от -5% до +5%
// Кривая потерь (решение №14, регламент 31.10, ТЕСТ): доля выбывших от каждого типа в зависимости от
// соотношения итоговых сил (сильная / слабая), в десятых долях процента. Между опорными точками -- плавно
// (линейно), без порогов-переключателей.
// Разгром (решения 31.10, п. 5): 7:1 -- 92%, 10:1 -- 95%, дальше слабая сторона плавно стремится к WeakLossMaxPermille (99%):
// v = max - (max - v_last) * R_last / R -- при 20:1 97%, при 100:1 98,6%; 100% не достигается.
// Сильная сторона (решения по отчёту Саши, 01.11, п. 1): 5:1 -- 11%, плавно до 2% при 10:1, дальше 2%.
public int[] LossRatioPermille = { 1000, 3000, 5000, 7000, 10000 }; // 1:1, 3:1, 5:1, 7:1, 10:1
public int[] StrongLossPermille = { 450, 160, 110, 74, 20 }; // сильная сторона: 45%, 16%, 11%, 7,4%, 2%
public int[] WeakLossPermille = { 450, 740, 880, 920, 950 }; // слабая сторона: 45%, 74%, 88%, 92%, 95%
public int WeakLossMaxPermille = 990; // верхний предел ожидаемых потерь слабой стороны: 99%
// Погибшие и раненые (регламент 31.10, ТЕСТ; решения 31.10, п. 7): из выбывших стороны погибает доля,
// зависящая от её соотношения сил (своя / чужая), остальные -- раненые. Ориентиры регламента:
// разгром 1:5 -- ~70%, поражение 1:2 -- ~45%, равный бой -- ~30%, уверенная победа 3:1 -- ~20%.
// Между точками -- плавно (линейно), за крайними -- значения крайних. Округление -- отдельным броском.
public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1
public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20%
// Пропавшие без вести (решения 01.11, п. 5): доля выбывших, получающих этот статус, в десятых долях процента.
// НЕ УТВЕРЖДЕНА командой -- по умолчанию 0 (механика выключена). Остальные выбывшие -- погибшие и раненые, как выше.
public int MissingSharePermille = 0;
// Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40%
public int FamineStrengthPercent = 50; // голод: -50%
public int FatigueStrengthPercent = 80; // усталость: -20%
+277 -69
View File
@@ -1,21 +1,37 @@
using System;
using System.Numerics;
// МОДУЛЬ БОЕВОЙ СИСТЕМЫ: урон, потери, исход. Документ: Docs/Modules/Battle.md
// Без Unity и без float: этот же код без изменений сможет считать бои на сервере.
//
// Правила (стартовые числа -- в BattleRules, утверждает команда):
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус) x (100% + бонусы стороны);
// Правила (стартовые числа -- в BattleRules; решения №11-13 комплекта требований от 29.10.2026):
// - сила стороны = сумма по типам: воины x сила типа x (100% + контры + осадный бонус)
// x (100% + бонусы стороны) x состояния (голод, усталость) x (100% + удача);
// - контры: пехота сильнее кавалерии, кавалерия сильнее лучников, лучники сильнее пехоты;
// бонус типа против врага взвешивается по составу врага;
// - осадные: против стен сильнее, в поле слабее; стены усиливают защитников;
// - случайность: каждой стороне от -5% до +5%, считается из Seed -- тот же Seed даёт тот же итог;
// - удача: каждой стороне от -5% до +5%, ПОСЛЕ всех модификаторов, из Seed -- тот же Seed даёт тот же итог (№12);
// - сила считается точной дробью (BigInteger) и округляется один раз -- только для показа (№12);
// - побеждает тот, у кого итоговая сила больше; ничья -- защитнику;
// - проигравший теряет половину каждого типа; победитель -- до половины, тем больше, чем ровнее был бой.
// Это не окончательная формула потерь (план от 27.10.2026): раненых пока нет, все выбывшие -- погибшие.
// - потери (№14): доля выбывших каждого типа -- плавная функция соотношения итоговых сил (сильная / слабая)
// по опорным точкам BattleRules: 1:1 -- 45% / 45%, 3:1 -- 16% / 74%, 5:1 -- 11% / 88%. Одинаково для PvE, PvP и Круга;
// - округление потерь: целая часть гарантирована, дробная -- шанс ещё одной потери,
// из отдельного потока случайности (№13). Раненых пока нет, все выбывшие -- погибшие.
public sealed class BattleSystem : IBattleSystem
{
// Точная неотрицательная дробь: сила x100 = N / D
struct Fraction
{
public BigInteger N, D;
public long Floor => (long)(N / D);
}
enum Rounding { Roll, Floor, Ceiling } // бой -- бросок по №13; прогноз -- лучший и худший случай
private readonly BattleRules rules;
private readonly int[] power;
private readonly int[] lossRatio, strongLoss, weakLoss; // кривая потерь (проверенная копия из правил)
private readonly int[] deadRatio, deadShare; // доля погибших среди выбывших (проверенная копия)
public BattleRules Rules => rules;
@@ -29,6 +45,71 @@ public sealed class BattleSystem : IBattleSystem
for (int i = 0; i < TroopType.Count; i++)
power[i] = this.rules.UnitPower != null && i < this.rules.UnitPower.Length && this.rules.UnitPower[i] > 0
? this.rules.UnitPower[i] : defaults[i];
// Кривая из таблицы, если она корректна, иначе -- значения по умолчанию (ошибка в Inspector не ломает бой)
var r = this.rules;
bool ok = r.LossRatioPermille != null && r.StrongLossPermille != null && r.WeakLossPermille != null
&& r.LossRatioPermille.Length > 0
&& r.LossRatioPermille.Length == r.StrongLossPermille.Length
&& r.LossRatioPermille.Length == r.WeakLossPermille.Length
&& r.LossRatioPermille[0] == 1000;
for (int i = 0; ok && i < r.LossRatioPermille.Length; i++)
{
ok = r.StrongLossPermille[i] >= 0 && r.StrongLossPermille[i] <= 1000 && r.WeakLossPermille[i] >= 0 && r.WeakLossPermille[i] <= 1000
&& (i == 0 || r.LossRatioPermille[i] > r.LossRatioPermille[i - 1]);
}
var src = ok ? r : BattleRules.Default;
lossRatio = (int[])src.LossRatioPermille.Clone();
strongLoss = (int[])src.StrongLossPermille.Clone();
weakLoss = (int[])src.WeakLossPermille.Clone();
// Доля погибших: та же проверка -- ошибка в таблице не ломает бой
bool deadOk = r.DeadRatioPermille != null && r.DeadSharePermille != null && r.DeadRatioPermille.Length > 0
&& r.DeadRatioPermille.Length == r.DeadSharePermille.Length;
for (int i = 0; deadOk && i < r.DeadRatioPermille.Length; i++)
deadOk = r.DeadRatioPermille[i] > 0 && r.DeadSharePermille[i] >= 0 && r.DeadSharePermille[i] <= 1000
&& (i == 0 || r.DeadRatioPermille[i] > r.DeadRatioPermille[i - 1]);
var deadSrc = deadOk ? r : BattleRules.Default;
deadRatio = (int[])deadSrc.DeadRatioPermille.Clone();
deadShare = (int[])deadSrc.DeadSharePermille.Clone();
// Предел не ниже последней точки и не выше 100%
weakLossMax = Math.Min(1000, Math.Max(weakLoss[weakLoss.Length - 1], r.WeakLossMaxPermille));
}
private int weakLossMax;
// Доля выбывших стороны (в десятых долях процента, дробью) при соотношении сил stronger / weaker (решение №14)
void LossShare(Fraction weaker, Fraction stronger, bool isWeak, out BigInteger num, out BigInteger den)
{
int[] values = isWeak ? weakLoss : strongLoss;
int last = lossRatio.Length - 1;
if (weaker.N.IsZero)
{
// У слабой стороны нулевая сила: сильная не теряет никого, слабая -- предел разгрома
num = isWeak ? weakLossMax : 0;
den = 1;
return;
}
// Соотношение сил в десятых долях процента: R = 1000 * stronger / weaker = rn / rd
BigInteger rn = 1000 * stronger.N * weaker.D;
BigInteger rd = stronger.D * weaker.N;
if (rn <= lossRatio[0] * rd) { num = values[0]; den = 1; return; }
if (rn >= lossRatio[last] * rd)
{
if (!isWeak) { num = values[last]; den = 1; return; }
// Дальше последней точки слабая сторона плавно стремится к пределу: v = max - (max - v_last) * R_last / R
num = (BigInteger)weakLossMax * rn - (BigInteger)(weakLossMax - values[last]) * lossRatio[last] * rd;
den = rn;
return;
}
int i = 0;
while (rn >= lossRatio[i + 1] * rd) i++; // lossRatio[i] <= R < lossRatio[i + 1]
long span = lossRatio[i + 1] - lossRatio[i];
// v = v_i + (v_{i+1} - v_i) * (R - p_i) / span -- точной дробью
num = (BigInteger)values[i] * span * rd + (BigInteger)(values[i + 1] - values[i]) * (rn - lossRatio[i] * rd);
den = span * rd;
}
// Контра: насколько тип attackerType сильнее против defenderType, в процентах
@@ -42,66 +123,88 @@ public sealed class BattleSystem : IBattleSystem
public BattleOutcome Resolve(BattleRequest request)
{
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2));
return Fight(request, Luck(request.Seed, 1), Luck(request.Seed, 2), Rounding.Roll);
}
// Прогноз: бой при крайних значениях случайности. Итог конкретного боя не раскрывается.
// Прогноз (№11): качественный риск и точный шанс победы. Итог конкретного боя не раскрывается.
public BattleForecast Forecast(BattleRequest request)
{
int r = rules.RandomPercent * 10;
int r = Math.Max(0, rules.RandomPercent * 10);
var f = new BattleForecast
{
AttackerPowerMin = long.MaxValue, DefenderPowerMin = long.MaxValue,
AttackerLossesMin = int.MaxValue
};
bool anyWin = false, anyLoss = false;
foreach (int attLuck in new[] { -r, r })
foreach (int defLuck in new[] { -r, r })
{
BattleOutcome o = Fight(request, attLuck, defLuck);
if (o.Victory) anyWin = true; else anyLoss = true;
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, o.AttackerPower);
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, o.AttackerPower);
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, o.DefenderPower);
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, o.DefenderPower);
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, o.AttackerLosses);
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, o.AttackerLosses);
BattleOutcome best = Fight(request, attLuck, defLuck, Rounding.Floor);
BattleOutcome worst = Fight(request, attLuck, defLuck, Rounding.Ceiling);
f.AttackerPowerMin = Math.Min(f.AttackerPowerMin, best.AttackerPower);
f.AttackerPowerMax = Math.Max(f.AttackerPowerMax, best.AttackerPower);
f.DefenderPowerMin = Math.Min(f.DefenderPowerMin, best.DefenderPower);
f.DefenderPowerMax = Math.Max(f.DefenderPowerMax, best.DefenderPower);
f.AttackerLossesMin = Math.Min(f.AttackerLossesMin, best.AttackerLosses);
f.AttackerLossesMax = Math.Max(f.AttackerLossesMax, worst.AttackerLosses);
}
f.Chance = !anyLoss ? BattleChance.Advantage : !anyWin ? BattleChance.HighRisk : BattleChance.Uncertain;
// Шанс победы: все сочетания удачи сторон равновероятны -- считаем, при скольких нападающий сильнее
Sides(request, out Fraction att, out Fraction def, out _, out _, out _, out _);
BigInteger x = att.N * def.D, y = def.N * att.D; // нападающий побеждает, если x * (1000 + a) > y * (1000 + d)
long wins = 0, total = (long)(2 * r + 1) * (2 * r + 1);
for (int a = -r; a <= r; a++)
{
BigInteger t = x * (1000 + a);
if (y.IsZero) { if (t > 0) wins += 2 * r + 1; continue; }
// нужно y * (1000 + d) < t, то есть 1000 + d <= ceil(t / y) - 1
BigInteger dMax = (t + y - 1) / y - 1 - 1000;
if (dMax < -r) continue;
wins += dMax >= r ? 2 * r + 1 : (long)dMax + r + 1;
}
f.WinChancePermille = (int)((wins * 1000 + total / 2) / total);
f.Chance = wins == total ? BattleChance.Advantage : wins == 0 ? BattleChance.HighRisk : BattleChance.Uncertain;
return f;
}
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille)
BattleOutcome Fight(BattleRequest request, int attLuckPermille, int defLuckPermille, Rounding rounding)
{
int[] att = Units(request.AttackerUnits, request.Attackers);
int[] def = Units(request.DefenderUnits, request.Defenders);
Sides(request, out Fraction attBase, out Fraction defBase, out int[] att, out int[] def,
out int attCondition, out int defCondition);
Fraction attPower = WithLuck(attBase, attLuckPermille);
Fraction defPower = WithLuck(defBase, defLuckPermille);
int attCondition = ConditionPercent(request.AttackerConditions);
int defCondition = ConditionPercent(request.DefenderConditions);
long attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent, attCondition, attLuckPermille);
long defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0), defCondition, defLuckPermille);
bool victory = attPower.N * defPower.D > defPower.N * attPower.D; // ничья -- в пользу защитника
bool victory = attPower > defPower; // ничья -- в пользу защитника
// Потери по кривой (№14): победитель -- сильная сторона, проигравший -- слабая; при ничьей соотношение 1:1
Fraction weaker = victory ? defPower : attPower;
Fraction stronger = victory ? attPower : defPower;
int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding);
int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding);
// Потери в процентах: проигравший -- LoserLossPercent; победитель -- то же x (сила проигравшего / сила победителя)
long stronger = Math.Max(attPower, defPower);
long weaker = Math.Min(attPower, defPower);
int[] attDead = Losses(att, weaker, stronger, isLoser: !victory);
int[] defDead = Losses(def, weaker, stronger, isLoser: victory);
// Раненых пока нет: доля раненых и погибших не утверждена (план от 27.10.2026)
// Выбывшие: сначала пропавшие без вести (решения 01.11, п. 5), остальные -- погибшие и раненые (решения 31.10, п. 7):
// доля погибших -- от соотношения сил своей стороны. Солдат -- ровно в одном статусе
int[] attMissing = Missing(attOut, request.Seed, side: 1, rounding);
int[] defMissing = Missing(defOut0, request.Seed, side: 2, rounding);
int[] attRest = new int[TroopType.Count], defRest = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++) { attRest[i] = attOut[i] - attMissing[i]; defRest[i] = defOut0[i] - defMissing[i]; }
int[] attDead = Dead(attRest, attPower, defPower, request.Seed, side: 1, rounding);
int[] defDead = Dead(defRest, defPower, attPower, request.Seed, side: 2, rounding);
int[] attWounded = new int[TroopType.Count];
int[] defWounded = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attWounded[i] = attRest[i] - attDead[i];
defWounded[i] = defRest[i] - defDead[i];
}
var attAlive = new int[TroopType.Count];
var defAlive = new int[TroopType.Count];
var defOut = new int[TroopType.Count];
for (int i = 0; i < TroopType.Count; i++)
{
attAlive[i] = att[i] - attDead[i] - attWounded[i];
defAlive[i] = def[i] - defDead[i] - defWounded[i];
defOut[i] = defDead[i] + defWounded[i];
attAlive[i] = att[i] - attOut[i];
defAlive[i] = def[i] - defOut0[i];
defOut[i] = defOut0[i];
}
return new BattleOutcome
@@ -109,11 +212,11 @@ public sealed class BattleSystem : IBattleSystem
Victory = victory,
BattleId = request.Seed,
RulesVersion = rules.Version,
AttackerLosses = Sum(attDead) + Sum(attWounded),
AttackerLosses = Sum(attOut),
DefenderLosses = Sum(defOut),
Survivors = Sum(attAlive),
AttackerPower = attPower,
DefenderPower = defPower,
AttackerPower = attPower.Floor, // округление -- только для показа
DefenderPower = defPower.Floor,
AttackerLuckPermille = attLuckPermille,
DefenderLuckPermille = defLuckPermille,
AttackerConditionPercent = attCondition,
@@ -126,73 +229,178 @@ public sealed class BattleSystem : IBattleSystem
DefenderDeadUnits = defDead,
AttackerWoundedUnits = attWounded,
DefenderWoundedUnits = defWounded,
AttackerMissingUnits = attMissing,
DefenderMissingUnits = defMissing,
DefenderLossUnits = defOut
};
}
// Сколько силы оставляют состояния стороны, в процентах. Штрафы -- последовательно: голод и усталость -- 40%
public int ConditionPercent(BattleCondition conditions)
// Силы сторон без удачи (точные дроби) и состав сторон
void Sides(BattleRequest request, out Fraction attPower, out Fraction defPower, out int[] att, out int[] def,
out int attCondition, out int defCondition)
{
int percent = 100;
if ((conditions & BattleCondition.Famine) != 0) percent = percent * rules.FamineStrengthPercent / 100;
if ((conditions & BattleCondition.Fatigue) != 0) percent = percent * rules.FatigueStrengthPercent / 100;
return Math.Max(0, percent);
att = Units(request.AttackerUnits, request.Attackers);
def = Units(request.DefenderUnits, request.Defenders);
attPower = SidePower(att, def, attacking: true, request.DefenderFortified, request.AttackerBonusPercent,
request.AttackerConditions, out attCondition);
defPower = SidePower(def, att, attacking: false, request.DefenderFortified,
request.DefenderBonusPercent + (request.DefenderFortified ? rules.WallsPercent : 0),
request.DefenderConditions, out defCondition);
}
// Сила стороны в сотых долях (x100), чтобы проценты считались в целых числах.
// Порядок: контры и осадные -> бонусы стороны -> состояния (голод, усталость) -> случайность
// (порядок бонусов и случайности -- открытый вопрос плана).
long SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent, int conditionPercent, int luckPermille)
// Сколько силы оставляют состояния стороны, в процентах (для показа). Штрафы -- последовательно: голод и усталость -- 40%
public int ConditionPercent(BattleCondition conditions)
{
ConditionFactor(conditions, out BigInteger num, out BigInteger den);
return (int)(num * 100 / den);
}
void ConditionFactor(BattleCondition conditions, out BigInteger num, out BigInteger den)
{
num = 1; den = 1;
if ((conditions & BattleCondition.Famine) != 0) { num *= Math.Max(0, rules.FamineStrengthPercent); den *= 100; }
if ((conditions & BattleCondition.Fatigue) != 0) { num *= Math.Max(0, rules.FatigueStrengthPercent); den *= 100; }
}
// Сила стороны x100 точной дробью. Порядок: контры и осадные -> бонусы стороны -> состояния; удача -- в WithLuck.
// Контра по составу врага (counterSum / enemyTotal) остаётся дробью -- без промежуточного округления (№12).
Fraction SidePower(int[] units, int[] enemy, bool attacking, bool fortified, int bonusPercent,
BattleCondition conditions, out int conditionPercent)
{
int enemyTotal = Sum(enemy);
long total = 0;
int e = Math.Max(1, enemyTotal);
BigInteger raw = 0;
for (int i = 0; i < TroopType.Count; i++)
{
if (units[i] <= 0) continue;
long counterSum = 0;
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
long counterPercent = enemyTotal > 0 ? counterSum / enemyTotal : 0;
if (enemyTotal > 0)
for (int j = 0; j < TroopType.Count; j++) counterSum += (long)Counter(i, j) * enemy[j];
int siegePercent = 0;
if (i == TroopType.Siege) siegePercent = attacking && fortified ? rules.SiegeVsWallsPercent : rules.SiegeInFieldPercent;
total += (long)units[i] * power[i] * (100 + counterPercent + siegePercent);
// воины x сила x (100 + осадный% + контра%), где контра% = counterSum / e
BigInteger term = (BigInteger)units[i] * power[i] * ((100L + siegePercent) * e + counterSum);
if (term > 0) raw += term;
}
total = Math.Max(0, total * (100 + bonusPercent) / 100);
total = total * conditionPercent / 100;
return total * (1000 + luckPermille) / 1000;
ConditionFactor(conditions, out BigInteger condNum, out BigInteger condDen);
conditionPercent = (int)(condNum * 100 / condDen);
return new Fraction
{
N = raw * Math.Max(0, 100 + bonusPercent) * condNum,
D = (BigInteger)e * 100 * condDen
};
}
// Случайность стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
static Fraction WithLuck(Fraction power, int luckPermille)
{
return new Fraction { N = power.N * (1000 + luckPermille), D = power.D * 1000 };
}
// Удача стороны в десятых долях процента: от -RandomPercent*10 до +RandomPercent*10.
// Считается из Seed (SplitMix64) -- одинаково на любой платформе, System.Random для этого не годится.
public int Luck(long seed, int side)
{
int range = rules.RandomPercent * 10;
if (range <= 0) return 0;
ulong x = (ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side;
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
x ^= x >> 31;
ulong x = Mix((ulong)seed + 0x9E3779B97F4A7C15UL * (ulong)side);
return (int)(x % (ulong)(2 * range + 1)) - range;
}
// Потери каждого типа: проигравший -- LoserLossPercent; победитель -- LoserLossPercent x ratioNum / ratioDen.
// Округление вниз: одиночный проигравший не гибнет -- открытый вопрос (Battle.md).
int[] Losses(int[] units, long ratioNum, long ratioDen, bool isLoser)
// Отдельный поток случайности для округления потерь (№13): своя "соль" на каждую сторону и тип войск,
// не пересекается с удачей. Возвращает число от 0 до 2^32 - 1.
static ulong LossRoll(long seed, int side, int type)
{
ulong x = Mix(((ulong)seed ^ 0xD1B54A32D192ED03UL) + 0x9E3779B97F4A7C15UL * (ulong)(16 * side + type + 1));
return x >> 32;
}
static ulong Mix(ulong x)
{
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
// Потери каждого типа (дробью): units x доля выбывших по кривой (№14).
// Целая часть гарантирована, дробная -- шанс ещё одной потери (№13).
int[] Losses(int[] units, Fraction weaker, Fraction stronger, bool isWeak, long seed, int side, Rounding rounding)
{
var lost = new int[TroopType.Count];
LossShare(weaker, stronger, isWeak, out BigInteger shareNum, out BigInteger shareDen);
for (int i = 0; i < TroopType.Count; i++)
{
if (units[i] <= 0) continue;
long l = isLoser
? (long)units[i] * rules.LoserLossPercent / 100
: ratioDen > 0 ? (long)units[i] * rules.LoserLossPercent * ratioNum / (100 * ratioDen) : 0;
lost[i] = (int)Math.Max(0, Math.Min(units[i], l));
BigInteger num = (BigInteger)units[i] * shareNum;
BigInteger den = 1000 * shareDen;
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side, i) * den < rest * (BigInteger.One << 32));
BigInteger l = whole + (extra ? 1 : 0);
lost[i] = (int)BigInteger.Min(units[i], l);
}
return lost;
}
// Пропавшие без вести среди выбывших каждого типа: доля из правил (не утверждена; 0 -- никого), дробная часть -- бросок
// из своего потока. Прогноз: Floor -- меньше пропавших, Ceiling -- больше
int[] Missing(int[] outUnits, long seed, int side, Rounding rounding)
{
var missing = new int[TroopType.Count];
int share = Math.Max(0, Math.Min(1000, rules.MissingSharePermille));
if (share == 0) return missing;
for (int i = 0; i < TroopType.Count; i++)
{
if (outUnits[i] <= 0) continue;
long num = (long)outUnits[i] * share;
long whole = num / 1000, rest = num % 1000;
bool extra = rest != 0 && (rounding == Rounding.Ceiling
|| rounding == Rounding.Roll && (LossRoll(seed, side + 24, i) * 1000UL >> 32) < (ulong)rest);
missing[i] = (int)Math.Min(outUnits[i], whole + (extra ? 1 : 0));
}
return missing;
}
// Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок
// из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше.
int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding)
{
var dead = new int[TroopType.Count];
DeadShare(own, enemy, out BigInteger shareNum, out BigInteger shareDen);
for (int i = 0; i < TroopType.Count; i++)
{
if (outUnits[i] <= 0) continue;
BigInteger num = (BigInteger)outUnits[i] * shareNum;
BigInteger den = 1000 * shareDen;
BigInteger whole = BigInteger.DivRem(num, den, out BigInteger rest);
bool extra = !rest.IsZero && (rounding == Rounding.Ceiling
|| rounding == Rounding.Roll && (BigInteger)LossRoll(seed, side + 8, i) * den < rest * (BigInteger.One << 32));
dead[i] = (int)BigInteger.Min(outUnits[i], whole + (extra ? 1 : 0));
}
return dead;
}
// Доля погибших (в десятых долях процента, дробью) при соотношении сил стороны R = 1000 * own / enemy
void DeadShare(Fraction own, Fraction enemy, out BigInteger num, out BigInteger den)
{
int last = deadRatio.Length - 1;
if (enemy.N.IsZero) { num = deadShare[last]; den = 1; return; } // у врага нет силы -- как при самом большом перевесе
BigInteger rn = 1000 * own.N * enemy.D;
BigInteger rd = own.D * enemy.N;
if (rn <= deadRatio[0] * rd) { num = deadShare[0]; den = 1; return; }
if (rn >= deadRatio[last] * rd) { num = deadShare[last]; den = 1; return; }
int i = 0;
while (rn >= deadRatio[i + 1] * rd) i++;
long span = deadRatio[i + 1] - deadRatio[i];
num = (BigInteger)deadShare[i] * span * rd + (BigInteger)(deadShare[i + 1] - deadShare[i]) * (rn - deadRatio[i] * rd);
den = span * rd;
}
static int[] Units(int[] units, int count)
{
var result = new int[TroopType.Count];
@@ -22,6 +22,18 @@ public interface IEconomy
float GetCapacity(ResourceType type);
}
// Защита ресурсов от грабежа (01.11, ТЕСТ). Сколько ресурса нельзя отнять в набеге: часть запаса прячут
// Склады и Амбары. Добыча набега по ресурсу = Lootable. Реализует слой приложения (App/ResourceProtection)
// по зданиям города; модули Боя и Мира берут отсюда, сколько можно унести у города.
// Кристаллы (решение 04.11): недоступны нападающему, пока он не изучил технологию «Грабёж».
// Технологий в игре ещё нет -- изучена ли она, передаёт тот, кто считает набег (attackerHasPlunder).
public interface IResourceProtection
{
float Protected(ResourceType type); // сколько спрятано от нападающего без «Грабежа»
float Lootable(ResourceType type); // сколько можно унести без «Грабежа» (кристаллы -- 0)
float Lootable(ResourceType type, bool attackerHasPlunder); // с учётом технологии нападающего
}
// Добыча через рабочих (этап 3). Реализует модуль Экономики (ProductionService).
public interface IProduction
{
@@ -86,6 +98,42 @@ public interface IPopulation
}
// ---------------- Армия: войска ----------------
// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры
public struct MissingInfo
{
public int Id;
public int Count; // ещё не вернулись и не списаны
public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь)
public long EndMs; // конец окна: кто не вернулся -- списан
}
// Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры
public struct HealingInfo
{
public int Id;
public int Type;
public int Count;
public long StartMs;
public long EndMs;
}
// Госпиталь -- здание Поселения (решения 31.10, п. 7): уровень, вместимость, улучшения.
// Пока здания нет, контракт не зарегистрирован -- Армия берёт временное число мест из таблицы HospitalConfig.
public interface IHospital
{
int Beds { get; } // мест для лечения: регламент 31.10 -- 20..60 по уровням I..V
}
// Заказ обучения в Казармах. Время -- миллисекунды часов игры (GameClock.UnixNowMs)
public struct TrainingOrderInfo
{
public int Id; // номер заказа -- не повторяется
public int Type; // тип войск (TroopType)
public int Count;
public long StartMs; // когда начнётся (заказы идут друг за другом)
public long EndMs; // когда воины будут готовы
}
public interface IArmy
{
int TypeCount { get; } // типов войск (пехота, лучники, кавалерия, осадные)
@@ -102,8 +150,13 @@ public interface IArmy
void AddTroops(int type, int count); // обучены / вернулись
int LoseFraction(double fraction); // потери в бою: доля от каждого типа; возвращает, сколько погибло
// Слоты похода (регламент 31.10, раздел 7): сколько походов игрока может идти одновременно.
// База 2 (ТЕСТ), технология «Военная логистика» поднимет до 7 -- уровни и цены ещё не утверждены.
int MarchSlots { get; }
int ActiveMarches { get; } // походов в пути сейчас (у каждого -- свой слот)
// Забрать свободных воинов в поход (запоминает состав похода).
// false -- ничего не взято: не хватает воинов или поход с таким номером уже есть.
// false -- ничего не взято: не хватает воинов, нет свободного слота или поход с таким номером уже есть.
bool TakeForMarch(int marchId, int count);
// Вернуть выживших из похода по тем же типам войск.
// Зачисляет только по известному походу и только один раз; false -- такого похода нет, никто не добавлен.
@@ -113,6 +166,45 @@ public interface IArmy
int[] GetMarchUnits(int marchId);
// Вернуть выживших точно по типам (бой посчитал потери каждого типа). Правила те же, что у ReturnFromMarch.
bool ReturnUnits(int marchId, int[] survivorUnits);
// ---- Раненые и лечение (решения по 8 вопросам Саши, 31.10, п. 7): состояние и очередь лечения -- у Армии ----
// Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (IHospital), цену лечения списывает Экономика.
// Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11).
int WoundedTotal { get; } // раненых всего: ждут места + лечатся
int GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4)
// ---- Пропавшие без вести (решения 01.11, п. 5) ----
// Не воюют и не едят, но занимают места в населении до возвращения или списания.
int MissingTotal { get; }
IReadOnlyList<MissingInfo> Missing { get; }
// Бой посчитал пропавших: забрать их из похода в группу пропавших. battleMs -- конец боя, travelMs -- обратный путь.
// Только известный поход и только один раз на поход; больше, чем ушло, не забрать.
bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs);
// Вернуть группы, время которых наступило (и офлайн), и списать тех, чьё окно кончилось. Сколько групп вернулось.
int ProcessMissing(long nowMs);
int GetWoundedWaiting(int type); // ждут места, по типам
IReadOnlyList<HealingInfo> Healing { get; } // лечатся сейчас
int HospitalBeds { get; } // мест в госпитале сейчас
// Вернуть из похода выживших и раненых точно по типам (раненые -- в госпиталь в момент nowMs).
// Только известный поход и только один раз; больше, чем ушло, не вернётся.
bool ReturnUnits(int marchId, int[] survivorUnits, int[] woundedUnits, long nowMs);
// Закончить лечение, срок которого наступил (и офлайн), и положить ждущих на освободившиеся места.
// Вылеченные возвращаются в город (SoldiersHealedEvent, один раз на группу). Возвращает, сколько групп вылечено.
int ProcessHealing(long nowMs);
// ---- Очередь обучения (решения по 8 вопросам Саши, 31.10, п. 8): единственный владелец -- Армия ----
// Жители для найма -- у Населения (IPopulation: Recruitable, резерв, TryRecruit, CompleteTraining),
// размер очереди -- от Казарм (передаёт вызывающий), цену списывает вызывающий через Экономику.
IReadOnlyList<TrainingOrderInfo> Training { get; }
// Можно ли поставить заказ: очередь не заполнена, жителей хватает. Ничего не меняет; false -- error для игрока.
bool CanQueueTraining(int type, int count, int queueSize, out string error);
// Поставить заказ в конец очереди, люди уходят из жителей в Казармы. false -- ничего не изменилось.
bool TryQueueTraining(int type, int count, long durationMs, int queueSize, long nowMs, out string error);
// Закончить заказы, срок которых наступил к nowMs (и пока игра была закрыта): воины переходят в Армию,
// каждый заказ -- один раз (TroopsTrainedEvent). Возвращает, сколько заказов закончено.
int ProcessTraining(long nowMs);
// Ускорение: все заказы заканчиваются в nowMs
int FinishAllTraining(long nowMs);
}
// ---------------- Глобальный мир: карта и перемещения ----------------
@@ -120,6 +212,103 @@ public interface IWorld
{
int ActiveMarches { get; }
bool TryStartMarch(string targetId, int soldiers, out string error);
// Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода
bool TryStartScout(string targetId, int soldiers, out string error);
// Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться
bool TryStartRecon(string targetId, int soldiers, int level, out string error);
}
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
// Координаты и марши ведёт Мир, разведка только читает их через этот контракт -- без прямых ссылок
// World <-> Intelligence. Координаты -- милли-единицы карты (1000 = 1 клетка).
public struct ArmyOnMap
{
public string Id; // устойчивый номер армии на карте ("march_12")
public string OwnerId; // владелец (LocalPlayer.Id -- свои войска)
public int X, Y; // где сейчас
public int ToX, ToY; // куда движется
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
}
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
public enum ReconTargetKind { None, Settlement, Castle, Camp }
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
public struct ReconTarget
{
public string Id;
public string Name;
public ReconTargetKind Kind;
public int[] Units; // гарнизон по типам (TroopType)
public bool Fortified; // защитники за стенами
public bool HasHero;
public string[] ResourceNames; // что можно взять (имена ResourceType)
public int[] ResourceAmounts;
}
public interface IWorldMap
{
int HalfWidth { get; } // границы карты от центра, милли-единицы
int HalfHeight { get; }
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
void GetArmies(long timeMs, List<ArmyOnMap> result);
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
bool TryGetReconTarget(string targetId, out ReconTarget target);
}
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
public enum MapVisibility
{
Unexplored, // неизведано
Explored, // исследовано: география известна, текущие движения не видны
Observed // наблюдается сейчас
}
// Что игрок знает о чужой армии: только приблизительно и только на момент наблюдения
public struct KnownContact
{
public string ArmyId;
public bool Identified; // владелец опознан
public string OwnerId; // null, пока не опознан
public bool Observed; // видна прямо сейчас; false -- последний контакт (устаревшие данные)
public int X, Y, ToX, ToY; // где видели последний раз и куда шла
public int ApproxMin, ApproxMax; // примерная численность (±20%), одна на событие наблюдения
public long LastSeenMs; // когда видели последний раз
}
// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения.
// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15%
public struct ReconReport
{
public string Id; // устойчивый: "recon_<номер похода>"
public string TargetId;
public string TargetName;
public int Level; // 2 или 3
public long TimeMs; // когда закончилось наблюдение (по часам игры)
public int[] Units; // гарнизон по типам на момент отчёта
public bool Fortified;
public bool HasHero;
public string Modifiers; // уровень III: основные боевые модификаторы защиты
public string[] ResourceNames; // уровень III: оценка ресурсов
public int[] ResourceMin, ResourceMax;
}
public interface IIntelligence
{
int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам)
MapVisibility GetVisibility(int x, int y); // по последнему расчёту
IReadOnlyList<KnownContact> Contacts { get; }
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
// ---- М2: целенаправленная разведка ----
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
// 0 -- цель нельзя разведать с отчётом
long ReconDurationMs(string targetId, int level);
// Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false)
bool CompleteRecon(string reportId, string targetId, int level, long timeMs);
IReadOnlyList<ReconReport> Reports { get; }
bool TryGetLatestReport(string targetId, out ReconReport report);
}
// ---------------- Боевая система: урон, потери, исход ----------------
@@ -187,7 +376,8 @@ public struct BattleOutcome
public int[] AttackerUnitsBefore, DefenderUnitsBefore;
public int[] AttackerSurvivorUnits, DefenderSurvivorUnits;
public int[] AttackerDeadUnits, DefenderDeadUnits;
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // пока нули: доля раненых не утверждена командой
public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7)
public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие
public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода)
}
@@ -202,9 +392,10 @@ public enum BattleChance
public struct BattleForecast
{
public BattleChance Chance;
public int WinChancePermille; // шанс победы нападающего в десятых долях процента: 273 = 27,3% (решение №11)
public long AttackerPowerMin, AttackerPowerMax; // x100, крайние значения случайности
public long DefenderPowerMin, DefenderPowerMax;
public int AttackerLossesMin, AttackerLossesMax;
public int AttackerLossesMin, AttackerLossesMax; // с учётом округления потерь (решение №13)
}
public interface IBattleSystem
@@ -32,6 +32,15 @@ public struct GameTimeAdvancedEvent
public long UnixNow;
}
// Расчёт игры (модуль Населения: добыча, еда, прирост) дошёл до момента UnixNow (04.11).
// Приходит в начале каждого шага расчёта (раз в 10 игровых минут офлайна) и в конце каждого Tick.
// Всё, что должно случиться по времени (стройка закончилась), применяется здесь -- тогда следующий шаг
// уже считается с новым городом, и добыча идёт ровно с момента завершения, а не с начала офлайна.
public struct GameTimeSimulatedEvent
{
public long UnixNow;
}
// ---------------- Население ----------------
// Число людей изменилось (прирост, загрузка, сброс, изменение войска)
public struct PopulationChangedEvent
@@ -62,19 +71,63 @@ public struct TroopsChangedEvent
public int AtHome;
public int InMarches;
public int Garrison; // из AtHome -- в гарнизоне
public int Wounded; // раненые: в госпитале или ждут места (не в AtHome)
public int Missing; // пропавшие без вести: занимают места в населении, но не едят и не воюют
}
// Пропавшие без вести вернулись (часть группы боя): здоровые -- в город, раненые -- в госпиталь. Один раз на группу
public struct MissingReturnedEvent
{
public int MissingId;
public int Group;
public int Healthy;
public int Wounded;
public long TimeMs;
}
// Окно возвращения кончилось: кто не вернулся -- списан, места в населении освобождены
public struct MissingWrittenOffEvent
{
public int MissingId;
public int Count;
public long TimeMs;
}
// Раненые вылечены и вернулись в город. Один раз на группу лечения (BatchId), TimeMs -- когда закончили
public struct SoldiersHealedEvent
{
public int BatchId;
public int Type;
public int Count;
public long TimeMs;
}
// Заказ обучения закончен: воины уже в Армии. Приходит один раз на заказ (OrderId), TimeMs -- когда был готов
public struct TroopsTrainedEvent
{
public int OrderId;
public int Type;
public int Count;
public long TimeMs;
}
// ---------------- Глобальный мир ----------------
// У событий похода -- номер похода и точное время по часам игры (документ 02 комплекта от 29.10.2026):
// TimeMs -- когда это случилось, а не когда игра это заметила (после офлайна это разные моменты).
public struct MarchStartedEvent
{
public int MarchId;
public long TimeMs; // выступление
public string TargetId;
public int Soldiers;
public bool IsScout; // поход «Разведка»: без боя и добычи
}
public struct MarchBattleResolvedEvent
{
public int MarchId;
public long BattleId; // номер боя (= Seed): по нему бой можно перепроверить
public long TimeMs; // приход к цели и бой
public string TargetId;
public bool Victory;
public int Soldiers;
@@ -84,10 +137,15 @@ public struct MarchBattleResolvedEvent
public struct MarchReturnedEvent
{
public int MarchId;
public long TimeMs; // возвращение домой
public bool Victory;
public int Survivors;
public int Sent; // сколько воинов уходило в поход
public bool TargetEmpty; // лагерь уже был разбит: боя не было, вернулись все
public bool TargetEmpty; // лагерь уже был разбит / ресурсной точки уже не было: боя и сбора не было, вернулись все
public bool IsGather; // поход на ресурсную точку (сбор, а не бой)
public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет
public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места)
public int Missing; // пропали без вести в бою -- могут вернуться сами
public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа)
public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет"
}
@@ -123,3 +181,53 @@ public struct NewGameStartedEvent { }
public struct GameSavedEvent { }
// Сохранения сброшены, все модули уже в состоянии новой игры
public struct GameResetEvent { }
// ---------------- Мир -> Разведка ----------------
// Мир сейчас продвинет время до ToMs: разведка успевает досчитать наблюдение по армиям, которые ещё на карте
// (после продвижения вернувшиеся походы исчезают). Публикует MarchManager перед каждым расчётом мира.
public struct WorldAdvancingEvent
{
public long ToMs;
}
// ---------------- Разведка ----------------
// У всех событий -- устойчивый номер события (армия + номер наблюдения) и время по часам игры (ТЗ разведки, раздел 10)
public struct ArmySpottedEvent // чужая армия вошла в зону обзора
{
public string EventId;
public string ArmyId;
public long TimeMs;
public int ApproxMin, ApproxMax;
}
public struct ArmyLostContactEvent // чужая армия вышла из зоны обзора -- остаётся метка последнего контакта
{
public string EventId;
public string ArmyId;
public long TimeMs;
}
// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10)
public struct ReconStartedEvent
{
public string EventId; // "recon_<номер похода>"
public string TargetId;
public int Level;
public long TimeMs;
}
public struct ReconCompletedEvent
{
public string EventId; // = id отчёта
public string TargetId;
public int Level;
public long TimeMs;
}
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
{
public string EventId;
public string ArmyId;
public string OwnerId;
public long TimeMs;
}
+4
View File
@@ -21,6 +21,10 @@ public static class GameClock
public static DateTime UtcNow => DateTimeOffset.FromUnixTimeSeconds(UnixNow).UtcDateTime;
// То же в миллисекундах -- для таймеров, которые идут плавно (обучение войск, марши)
public static long UnixNowMs => (RealUnixSource != null ? RealUnixSource() * 1000 : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
+ offsetSeconds * 1000;
// Промотать время вперёд (отладка). Модули узнают об этом из GameTimeAdvancedEvent и досчитываются.
public static void Advance(long seconds)
{
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4b19799fef59420d948583d7bd00e661
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Intelligence",
"rootNamespace": "",
"references": ["Bogatyri.Core"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 263eeba80895448da32cd4c9816b18f3
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,471 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
// МОДУЛЬ РАЗВЕДКИ, этап М1: туман войны, источники обзора, чужие армии, последний контакт.
// Документ: Docs/Modules/Intelligence.md. ТЗ разведки 31.10.2026, решение №46: отдельная сборка Bogatyri.Intelligence.
//
// Без Unity. Карту и армии не хранит и не двигает -- читает их у Мира через контракт IWorldMap.
// Своё: туман (исследованные клетки), известные контакты, номера событий наблюдения. Чужие данные не меняет.
//
// Числа -- ТЕСТ, в игре из таблицы IntelligenceConfig.
public sealed class IntelligenceRules
{
public int SettlementRadius = 5000; // обзор своего поселения: 5 клеток
public int ArmyRadius = 1000; // обзор обычной армии: 1 клетка
public int[] ObservationPercent = { 0, 10, 20, 30 }; // технология «Обзор» I/II/III -- не складывается, заменяет
public long LastContactMs = 10 * 60_000; // последний контакт живёт 10 РЕАЛЬНЫХ минут
public int ApproxPercent = 20; // примерная численность: ±20%
public int IdentifyPercent = 50; // опознание чужого владельца: 50% на событие наблюдения
public long SampleStepMs = 10_000; // шаг расчёта, пока игра была закрыта: 10 реальных секунд
public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов
public int CellSize = 1000; // клетка тумана = клетка карты
// М2, целенаправленная разведка (ТЗ, раздел 6): сколько разведчики наблюдают цель, ИГРОВЫХ часов (уровень II, III)
public int[] ReconSettlementGameHours = { 2, 4 };
public int[] ReconCastleGameHours = { 3, 6 };
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
public int MaxReports = 30; // храним последние отчёты
public static IntelligenceRules Default => new IntelligenceRules();
}
public sealed class IntelligenceService : IIntelligence, ISaveParticipant
{
// Что игрок знает о чужой армии. Номер наблюдения -- для устойчивых событий и воспроизводимых бросков
class Contact
{
public string armyId;
public string ownerId; // настоящий владелец (из Мира) -- игроку показывается только после опознания
public bool identified;
public bool observed;
public int x, y, toX, toY;
public int approxMin, approxMax;
public long lastSeenMs;
public int observation; // сколько раз армия входила в обзор (событие наблюдения)
}
private readonly IntelligenceRules rules;
private readonly Func<IWorldMap> worldMap;
private readonly string localPlayer;
private readonly Dictionary<string, Contact> contacts = new Dictionary<string, Contact>();
private readonly List<KnownContact> contactView = new List<KnownContact>();
private readonly List<ArmyOnMap> armies = new List<ArmyOnMap>();
private readonly List<(int x, int y, int r)> sources = new List<(int, int, int)>();
private readonly HashSet<string> seen = new HashSet<string>();
private bool[] explored = Array.Empty<bool>();
private int gridW, gridH, halfW, halfH;
private long lastTickMs;
private bool viewDirty = true;
public IntelligenceService(IntelligenceRules rules = null, Func<IWorldMap> worldMap = null, string localPlayerId = LocalPlayer.Id)
{
this.rules = rules ?? IntelligenceRules.Default;
this.worldMap = worldMap ?? (() => GameServices.TryGet(out IWorldMap m) ? m : null);
localPlayer = localPlayerId;
}
public IntelligenceRules Rules => rules;
// ---------------- Контракт IIntelligence ----------------
private int observationLevel;
public int ObservationLevel
{
get => observationLevel;
set => observationLevel = Math.Max(0, Math.Min(rules.ObservationPercent.Length - 1, value));
}
// Радиус с «Обзором»: процент текущего уровня, дробные радиусы без округления до клеток
public int Radius(int baseRadius)
{
return (int)((long)baseRadius * (100 + rules.ObservationPercent[observationLevel]) / 100);
}
public MapVisibility GetVisibility(int x, int y)
{
foreach (var s in sources)
if (Inside(x, y, s.x, s.y, s.r)) return MapVisibility.Observed;
int cell = Cell(x, y);
return cell >= 0 && explored[cell] ? MapVisibility.Explored : MapVisibility.Unexplored;
}
public IReadOnlyList<KnownContact> Contacts
{
get
{
if (viewDirty)
{
contactView.Clear();
foreach (var c in contacts.Values)
contactView.Add(new KnownContact
{
ArmyId = c.armyId,
Identified = c.identified,
OwnerId = c.identified ? c.ownerId : null,
Observed = c.observed,
X = c.x, Y = c.y, ToX = c.toX, ToY = c.toY,
ApproxMin = c.approxMin, ApproxMax = c.approxMax,
LastSeenMs = c.lastSeenMs
});
viewDirty = false;
}
return contactView;
}
}
// Наблюдение от прошлого расчёта до nowMs шагами SampleStepMs: армия, прошедшая через обзор,
// пока игра была закрыта, тоже будет замечена -- в хронологическом порядке (ТЗ, приёмка 9)
public void ObserveUntil(long nowMs)
{
IWorldMap map = worldMap();
if (map == null) return;
EnsureGrid(map);
long from = lastTickMs == 0 ? nowMs : Math.Max(lastTickMs, nowMs - rules.MaxCatchUpMs);
if (nowMs < from) return; // время не идёт назад: повторный расчёт ничего не меняет
for (long t = from + rules.SampleStepMs; t < nowMs; t += rules.SampleStepMs) Sample(map, t);
Sample(map, nowMs);
lastTickMs = nowMs;
// Последний контакт стареет и удаляется через LastContactMs (ТЗ, раздел 2)
var expired = new List<string>();
foreach (var c in contacts.Values)
if (!c.observed && nowMs - c.lastSeenMs >= rules.LastContactMs) expired.Add(c.armyId);
foreach (var id in expired) contacts.Remove(id);
if (expired.Count > 0) viewDirty = true;
}
void Sample(IWorldMap map, long t)
{
armies.Clear();
map.GetArmies(t, armies);
// Источники обзора: своё поселение и свои армии (башни и герой-разведчик -- позже)
sources.Clear();
if (map.TryGetSettlement(localPlayer, out int sx, out int sy)) sources.Add((sx, sy, Radius(rules.SettlementRadius)));
foreach (var a in armies)
if (a.OwnerId == localPlayer) sources.Add((a.X, a.Y, Radius(rules.ArmyRadius)));
foreach (var s in sources) Explore(s.x, s.y, s.r);
seen.Clear();
foreach (var a in armies)
{
if (a.OwnerId == localPlayer) continue; // свои -- не разведка
bool inView = false;
foreach (var s in sources) if (Inside(a.X, a.Y, s.x, s.y, s.r)) { inView = true; break; }
if (!inView) continue;
seen.Add(a.Id);
Observe(a, t);
}
// Ушла из обзора (или исчезла с карты) -- контакт потерян, данные больше не обновляются
foreach (var c in contacts.Values)
{
if (!c.observed || seen.Contains(c.armyId)) continue;
c.observed = false;
viewDirty = true;
EventBus.Publish(new ArmyLostContactEvent { EventId = EventId(c), ArmyId = c.armyId, TimeMs = t });
}
}
void Observe(ArmyOnMap a, long t)
{
if (!contacts.TryGetValue(a.Id, out Contact c))
{
c = new Contact { armyId = a.Id, ownerId = a.OwnerId };
contacts[a.Id] = c;
}
if (!c.observed)
{
// Новое событие наблюдения: примерная численность и бросок опознания -- один раз на событие
c.observed = true;
c.observation++;
int width = Math.Max(1, a.Soldiers * 2 * rules.ApproxPercent / 100); // ±20% -> окно 40%
int shift = (int)(Roll(a.Id, c.observation, 1) % (ulong)(width + 1));
c.approxMin = Math.Max(1, a.Soldiers - shift); // настоящая численность всегда внутри окна
c.approxMax = c.approxMin + width;
EventBus.Publish(new ArmySpottedEvent
{ EventId = EventId(c), ArmyId = a.Id, TimeMs = t, ApproxMin = c.approxMin, ApproxMax = c.approxMax });
if (!c.identified && Roll(a.Id, c.observation, 2) % 100 < (ulong)rules.IdentifyPercent)
{
c.identified = true;
EventBus.Publish(new ArmyIdentifiedEvent { EventId = EventId(c), ArmyId = a.Id, OwnerId = a.OwnerId, TimeMs = t });
}
}
c.x = a.X; c.y = a.Y; c.toX = a.ToX; c.toY = a.ToY;
c.lastSeenMs = t;
viewDirty = true;
}
static string EventId(Contact c) => c.armyId + "#" + c.observation.ToString(CultureInfo.InvariantCulture);
// Детерминированный бросок: армия + номер наблюдения + назначение. Повторный расчёт, загрузка или клик
// не перебрасывают -- одно событие наблюдения, один результат (ТЗ, приёмка 5)
static ulong Roll(string armyId, int observation, int purpose)
{
ulong h = 14695981039346656037UL;
foreach (char ch in armyId) h = (h ^ ch) * 1099511628211UL;
h ^= (ulong)observation * 0x9E3779B97F4A7C15UL + (ulong)purpose;
h = (h ^ (h >> 30)) * 0xBF58476D1CE4E5B9UL;
h = (h ^ (h >> 27)) * 0x94D049BB133111EBUL;
return h ^ (h >> 31);
}
// ---------------- Туман: исследованные клетки ----------------
void EnsureGrid(IWorldMap map)
{
if (map.HalfWidth == halfW && map.HalfHeight == halfH && explored.Length > 0) return;
halfW = map.HalfWidth;
halfH = map.HalfHeight;
gridW = Math.Max(1, (2 * halfW + rules.CellSize - 1) / rules.CellSize);
gridH = Math.Max(1, (2 * halfH + rules.CellSize - 1) / rules.CellSize);
var old = explored;
explored = new bool[gridW * gridH];
if (old.Length == explored.Length) Array.Copy(old, explored, old.Length);
}
int Cell(int x, int y)
{
if (gridW == 0) return -1;
int cx = (x + halfW) / rules.CellSize, cy = (y + halfH) / rules.CellSize;
if (cx < 0 || cy < 0 || cx >= gridW || cy >= gridH) return -1;
return cy * gridW + cx;
}
// Исследована клетка, центр которой попал в круг обзора
void Explore(int x, int y, int r)
{
int c = rules.CellSize;
for (int cy = Math.Max(0, (y - r + halfH) / c); cy <= Math.Min(gridH - 1, (y + r + halfH) / c); cy++)
for (int cx = Math.Max(0, (x - r + halfW) / c); cx <= Math.Min(gridW - 1, (x + r + halfW) / c); cx++)
{
int px = cx * c + c / 2 - halfW, py = cy * c + c / 2 - halfH;
if (Inside(px, py, x, y, r)) explored[cy * gridW + cx] = true;
}
}
static bool Inside(int x, int y, int cx, int cy, int r)
{
long dx = x - cx, dy = y - cy;
return dx * dx + dy * dy <= (long)r * r;
}
public int ExploredCells
{
get { int n = 0; foreach (var e in explored) if (e) n++; return n; }
}
// ---------------- М2: целенаправленная разведка и отчёты ----------------
private readonly List<ReconReport> reports = new List<ReconReport>();
public IReadOnlyList<ReconReport> Reports => reports;
public long ReconDurationMs(string targetId, int level)
{
IWorldMap map = worldMap();
if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0;
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
: t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null;
if (hours == null || hours.Length < level - 1) return 0;
return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
}
// Отчёт -- снимок цели на момент конца наблюдения. Один отчёт на операцию: повтор (загрузка, двойное событие) -- false
public bool CompleteRecon(string reportId, string targetId, int level, long timeMs)
{
if (string.IsNullOrEmpty(reportId) || reports.Exists(r => r.Id == reportId)) return false;
IWorldMap map = worldMap();
if (map == null || !map.TryGetReconTarget(targetId, out ReconTarget t)) return false;
level = Math.Max(2, Math.Min(3, level));
var report = new ReconReport
{
Id = reportId, TargetId = targetId, TargetName = t.Name, Level = level, TimeMs = timeMs,
Units = t.Units != null ? (int[])t.Units.Clone() : new int[4],
Fortified = t.Fortified, HasHero = t.HasHero,
Modifiers = "", ResourceNames = Array.Empty<string>(), ResourceMin = Array.Empty<int>(), ResourceMax = Array.Empty<int>()
};
if (level >= 3)
{
report.Modifiers = (t.Fortified ? "защитники за стенами" : "бой в поле, стен нет") + (t.HasHero ? ", есть герой" : ", героя нет");
int n = t.ResourceNames != null ? t.ResourceNames.Length : 0;
report.ResourceNames = new string[n];
report.ResourceMin = new int[n];
report.ResourceMax = new int[n];
for (int i = 0; i < n; i++)
{
int amount = t.ResourceAmounts != null && i < t.ResourceAmounts.Length ? Math.Max(0, t.ResourceAmounts[i]) : 0;
int width = amount * 2 * rules.ResourceEstimatePercent / 100; // ±15% -> окно 30%, настоящее значение внутри
int shift = width > 0 ? (int)(Roll(reportId, i, 3) % (ulong)(width + 1)) : 0;
report.ResourceNames[i] = t.ResourceNames[i];
report.ResourceMin[i] = Math.Max(0, amount - shift);
report.ResourceMax[i] = report.ResourceMin[i] + width;
}
}
reports.Add(report);
while (reports.Count > Math.Max(1, rules.MaxReports)) reports.RemoveAt(0);
EventBus.Publish(new ReconCompletedEvent { EventId = reportId, TargetId = targetId, Level = level, TimeMs = timeMs });
return true;
}
public bool TryGetLatestReport(string targetId, out ReconReport report)
{
for (int i = reports.Count - 1; i >= 0; i--)
if (reports[i].TargetId == targetId) { report = reports[i]; return true; }
report = default;
return false;
}
static string Csv(int[] v)
{
if (v == null) return "";
var parts = new string[v.Length];
for (int i = 0; i < v.Length; i++) parts[i] = v[i].ToString(CultureInfo.InvariantCulture);
return string.Join(",", parts);
}
static int[] ParseCsv(string s)
{
if (string.IsNullOrEmpty(s)) return Array.Empty<int>();
string[] parts = s.Split(',');
var v = new int[parts.Length];
for (int i = 0; i < parts.Length; i++) v[i] = I(parts[i]);
return v;
}
static string Clean(string s) => (s ?? "").Replace("|", "/").Replace("\n", " ");
void RestoreReport(string[] p)
{
if (p.Length < 12 || reports.Exists(r => r.Id == p[0])) return;
long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long time);
reports.Add(new ReconReport
{
Id = p[0], TargetId = p[1], TargetName = p[2], Level = I(p[3]), TimeMs = time,
Units = ParseCsv(p[5]), Fortified = p[6] == "1", HasHero = p[7] == "1", Modifiers = p[8],
ResourceNames = string.IsNullOrEmpty(p[9]) ? Array.Empty<string>() : p[9].Split(','),
ResourceMin = ParseCsv(p[10]), ResourceMax = ParseCsv(p[11])
});
}
// ---------------- Сохранение (секция "intelligence") ----------------
// Простые строки (модуль без Unity):
// tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex>
// contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения>
// report=<id>|<цель>|<название>|<уровень>|<время, мс>|<войска,..>|<стены 1/0>|<герой 1/0>|<модификаторы>|<ресурсы,..>|<мин,..>|<макс,..>
public string SaveKey => "intelligence";
public string Capture()
{
var sb = new StringBuilder();
sb.Append("tick=").Append(lastTickMs.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("obzor=").Append(observationLevel.ToString(CultureInfo.InvariantCulture)).Append('\n');
sb.Append("grid=").Append(gridW).Append('x').Append(gridH).Append(':').Append(PackBits(explored)).Append('\n');
foreach (var c in contacts.Values)
sb.Append("contact=").Append(string.Join("|", new[]
{
c.armyId, c.ownerId ?? "", c.identified ? "1" : "0", c.observed ? "1" : "0",
N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax),
c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation)
})).Append('\n');
foreach (var r in reports)
sb.Append("report=").Append(string.Join("|", new[]
{
Clean(r.Id), Clean(r.TargetId), Clean(r.TargetName), N(r.Level), r.TimeMs.ToString(CultureInfo.InvariantCulture),
Csv(r.Units), r.Fortified ? "1" : "0", r.HasHero ? "1" : "0", Clean(r.Modifiers),
string.Join(",", r.ResourceNames ?? Array.Empty<string>()), Csv(r.ResourceMin), Csv(r.ResourceMax)
})).Append('\n');
return sb.ToString();
}
public void Restore(string data)
{
ResetState();
if (string.IsNullOrEmpty(data)) return;
foreach (var raw in data.Split('\n'))
{
string line = raw.Trim();
if (line.StartsWith("tick=")) long.TryParse(line.Substring(5), NumberStyles.Integer, CultureInfo.InvariantCulture, out lastTickMs);
else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl;
else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5));
else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|'));
else if (line.StartsWith("report=")) RestoreReport(line.Substring(7).Split('|'));
}
viewDirty = true;
}
public void ResetState()
{
contacts.Clear();
reports.Clear();
explored = Array.Empty<bool>();
gridW = gridH = halfW = halfH = 0;
lastTickMs = 0;
observationLevel = 0;
sources.Clear();
viewDirty = true;
}
void RestoreGrid(string s)
{
int x = s.IndexOf('x'), colon = s.IndexOf(':');
if (x < 0 || colon < x) return;
if (!int.TryParse(s.Substring(0, x), out int w) || !int.TryParse(s.Substring(x + 1, colon - x - 1), out int h)) return;
if (w <= 0 || h <= 0) return;
gridW = w; gridH = h;
explored = UnpackBits(s.Substring(colon + 1), w * h);
// halfW/halfH уточнит EnsureGrid при первом расчёте; размер сетки совпадёт -- клетки сохранятся
}
void RestoreContact(string[] p)
{
if (p.Length < 12) return;
var c = new Contact
{
armyId = p[0], ownerId = p[1].Length > 0 ? p[1] : null,
identified = p[2] == "1", observed = p[3] == "1",
x = I(p[4]), y = I(p[5]), toX = I(p[6]), toY = I(p[7]),
approxMin = I(p[8]), approxMax = I(p[9]),
observation = I(p[11])
};
long.TryParse(p[10], NumberStyles.Integer, CultureInfo.InvariantCulture, out c.lastSeenMs);
if (!string.IsNullOrEmpty(c.armyId)) contacts[c.armyId] = c;
}
static string N(int v) => v.ToString(CultureInfo.InvariantCulture);
static int I(string s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) ? v : 0;
static string PackBits(bool[] bits)
{
var sb = new StringBuilder();
for (int i = 0; i < bits.Length; i += 4)
{
int v = 0;
for (int k = 0; k < 4 && i + k < bits.Length; k++) if (bits[i + k]) v |= 1 << k;
sb.Append("0123456789abcdef"[v]);
}
return sb.ToString();
}
static bool[] UnpackBits(string hex, int count)
{
var bits = new bool[count];
for (int i = 0; i < hex.Length && i * 4 < count; i++)
{
int v = "0123456789abcdef".IndexOf(char.ToLowerInvariant(hex[i]));
if (v < 0) continue;
for (int k = 0; k < 4 && i * 4 + k < count; k++) bits[i * 4 + k] = (v & (1 << k)) != 0;
}
return bits;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6be01b8e525a455593482d7159414485
@@ -12,6 +12,10 @@ public sealed class PopulationRules
// Еда в час
public double FoodPerCivilianPerHour = 0.1;
public double FoodPerSoldierPerHour = 0.2;
// Еда воина по типам войск: пехота, лучники, кавалерия, осадные (решения по отчёту Саши, 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15).
// Раненые едят полную норму своего типа, даже ожидая койку. null -- все воины едят FoodPerSoldierPerHour (как раньше).
// Новобранцы в Казармах -- по FoodPerSoldierPerHour (их норма не задана).
public double[] FoodPerTroopTypePerHour = null;
// Прирост +2–3 человека в час, если есть еда
public double GrowthPerHour = 2.5;
@@ -15,7 +15,7 @@ using System.Text;
// свободный житель = Civilians - Workers (Idle)
// рабочий = Workers (jobs[], только из жителей: SetWorkers берёт не больше свободных)
// новобранец = InTraining (TryRecruit забирает только свободных, рабочих не трогает)
// воин = Soldiers (хранит Армия; RecruitsTrained убирает новобранцев до зачисления в Армию)
// воин = Soldiers (хранит Армия: в городе, в походах и раненые; RecruitsTrained убирает новобранцев до зачисления в Армию)
// Total = Civilians + InTraining + Soldiers -- сумма всех групп.
// Рабочие места сами не заполняются: только стартовая расстановка (AutoFillAll, один раз), дальше -- игрок.
// Найм воина: свободный житель уходит в Казармы (InTraining), после обучения его считает Армия. Резерв не нанять.
@@ -79,7 +79,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
get
{
if (!GameServices.TryGet(out IArmy army)) return 0;
return army.SoldiersAtHome + army.TroopsInMarches;
// раненые -- тоже люди (решения 31.10, п. 7); пропавшие без вести занимают места до возвращения (решения 01.11, п. 5)
return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal + army.MissingTotal;
}
}
@@ -182,7 +183,19 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
double FoodPerHourExact(int soldiers)
{
// Обучающиеся в Казармах едят как воины
return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour;
return civilians * rules.FoodPerCivilianPerHour + SoldiersFood(soldiers) + inTraining * rules.FoodPerSoldierPerHour;
}
// Воины (в городе, в походах и раненые) едят по норме своего типа, если она задана (решения 01.11, п. 4)
double SoldiersFood(int soldiers)
{
var table = rules.FoodPerTroopTypePerHour;
int missingNow = GameServices.TryGet(out IArmy a) ? a.MissingTotal : 0; // пропавшие не едят
if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return Math.Max(0, soldiers - missingNow) * rules.FoodPerSoldierPerHour;
double food = 0;
for (int t = 0; t < army.TypeCount; t++)
food += army.GetAllTroops(t) * (t < table.Length ? table[t] : rules.FoodPerSoldierPerHour);
return food;
}
public int GetWorkers(WorkJob job)
@@ -221,17 +234,22 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
{
lastTickUnix = unixNow; // первый расчёт: отсюда и считаем
Changed();
SimulatedUntil(unixNow);
return;
}
long elapsed = unixNow - lastTickUnix;
if (elapsed < 0)
{
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне) -- просто продолжаем с "сейчас"
lastTickUnix = unixNow;
// Часы ушли назад (сброс отладочной перемотки, перевод часов на телефоне). Метку НЕ двигаем назад:
// это время уже посчитано, и когда часы снова дойдут до него, оно не начислится второй раз (04.11).
// Но сообщить «расчёт дошёл до сейчас» нужно: время до сейчас тоже уже посчитано, и стройки, у которых оно
// вышло, завершаются. Иначе после «Сбросить перемотку» все стройки висели на 00:00, пока реальное время
// не догонит перемотку (05.11). Ресурсы здесь не начисляются
SimulatedUntil(unixNow);
return;
}
if (elapsed == 0) return;
if (elapsed == 0) { SimulatedUntil(unixNow); return; }
long max = (long)rules.MaxOfflineHours * SecondsPerHour;
if (elapsed > max) elapsed = max;
@@ -239,6 +257,13 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
Simulate(unixNow - elapsed, elapsed);
lastTickUnix = unixNow;
PruneFamine(unixNow);
SimulatedUntil(unixNow);
}
// Расчёт дошёл до момента: то, что случается по времени (стройка закончилась), применяется сейчас (GameTimeSimulatedEvent)
static void SimulatedUntil(long unixNow)
{
EventBus.Publish(new GameTimeSimulatedEvent { UnixNow = unixNow });
}
// ---------------- Расчёт ----------------
@@ -260,6 +285,10 @@ public sealed class PopulationService : IPopulation, ISaveParticipant
remaining -= step;
double hours = step / (double)SecondsPerHour;
// Расчёт дошёл до начала шага: стройки, закончившиеся к этому моменту, вступают в силу,
// и добыча этого шага считается уже с ними (а не с начала офлайна)
SimulatedUntil(stepStart);
// 0. Добыча: рабочие приносят ресурсы (в том числе еду) -- до того, как город поест
production?.Produce(hours);
+24 -2
View File
@@ -34,6 +34,23 @@ public class BuildGrid : MonoBehaviour
[Header("Где можно строить")]
[Tooltip("Посчитать область самому: старые участки + Ратуша + запас")]
public bool autoArea = true;
// Отладка (05.11, просьба Миши): сетка видна всегда и ярче, каждая 5-я линия жирнее -- чтобы примерять
// к ней размеры зданий и референсы. Включается кнопкой "Сетка" в панели DEV.
public static bool DebugVisible { get; private set; }
public static void SetDebugVisible(bool on, bool buildModeActive)
{
DebugVisible = on;
if (BuildManager.Instance == null) return; // 2D-город не открыт -- сетка появится, когда откроется
var grid = Instance;
if (grid == null) return;
grid.lastOverlayKey = null;
grid.ShowOverlay(on || buildModeActive);
}
// Здание, которое сейчас переносят: его клетки считаются свободными (режим стройки, перенос)
public static GameObject IgnoredBuilding;
[Tooltip("Запас вокруг старых участков, клеток")]
public int padding = 3;
public Vector2 areaMin = new Vector2(-34, -10);
@@ -218,6 +235,7 @@ public class BuildGrid : MonoBehaviour
foreach (var id in FindObjectsByType<PlacedBuildingId>(FindObjectsSortMode.None))
{
if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре
if (IgnoredBuilding != null && id.gameObject == IgnoredBuilding) continue; // его переносят
BuildingData data = bm != null ? bm.FindData(id.buildingName) : null;
Rect r;
if (data != null)
@@ -254,6 +272,7 @@ public class BuildGrid : MonoBehaviour
public void ShowOverlay(bool show)
{
EnsureInit();
show |= DebugVisible; // отладочная сетка не прячется при выходе из режима стройки
if (show && overlay == null) CreateOverlay();
if (overlay == null) return;
overlay.enabled = show;
@@ -279,7 +298,8 @@ public class BuildGrid : MonoBehaviour
void RedrawOverlay()
{
// перерисовываем только если занятость поменялась
var key = new System.Text.StringBuilder(Width * Height);
var key = new System.Text.StringBuilder(Width * Height + 1);
key.Append(DebugVisible ? 'D' : 'n');
for (int y = 0; y < Height; y++) for (int x = 0; x < Width; x++) key.Append(busy[x, y] ? '1' : '0');
string k = key.ToString();
if (k == lastOverlayKey) return;
@@ -287,7 +307,8 @@ public class BuildGrid : MonoBehaviour
int w = overlayTex.width, h = overlayTex.height;
var px = new Color32[w * h];
Color32 line = lineColor, free = freeColor, bad = busyColor;
Color32 line = DebugVisible ? new Color(1f, 1f, 1f, 0.7f) : lineColor, free = freeColor, bad = busyColor;
Color32 major = new Color32(255, 230, 80, 230); // каждая 5-я клетка -- жёлтая линия (только в отладке)
for (int y = 0; y < h; y++)
{
int cy = y / PixelsPerCell, iy = y % PixelsPerCell;
@@ -296,6 +317,7 @@ public class BuildGrid : MonoBehaviour
int cx = x / PixelsPerCell, ix = x % PixelsPerCell;
bool edge = ix == 0 || iy == 0 || x == w - 1 || y == h - 1;
px[y * w + x] = busy[cx, cy] ? bad : (edge ? line : free);
if (DebugVisible && ((ix == 0 && cx % 5 == 0) || (iy == 0 && cy % 5 == 0))) px[y * w + x] = major;
if (busy[cx, cy] && edge) px[y * w + x] = new Color32(bad.r, bad.g, bad.b, (byte)Mathf.Min(255, bad.a + 50));
}
}
@@ -170,6 +170,8 @@ public class BuildManager : MonoBehaviour
var grid = BuildGrid.Instance;
grid.Refresh(); // занятость на этот момент, а не 0.3 с назад
if (!CanBuildAt(data, cell)) return null;
long buildMs = BuildTimer.DurationMs(false, 1);
if (buildMs > 0 && BuildTimer.FreeBuilders == 0) return null; // все строители заняты
data.PayCost();
@@ -180,6 +182,11 @@ public class BuildManager : MonoBehaviour
GameObject built = Instantiate(data.prefab, pos, Quaternion.identity);
if (!built.activeSelf) built.SetActive(true); // шаблон казарм хранится выключенным
built.AddComponent<PlacedBuildingId>().buildingName = data.buildingName;
if (buildMs > 0)
{
long now = GameClock.UnixNowMs;
BuildTimer.Begin(built, BuildTimer.Kind.Build, 1, now, now + buildMs); // стоит, но не работает, пока строится
}
Debug.Log($"[СТРОЙКА] Построено: {data.buildingName}, клетки {cell} размер {fp.size}");
EventBus.Publish(new BuildingPlacedEvent { BuildingName = data.buildingName });
@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md
// Стройка или улучшение здания, которое ещё идёт (01.11, числа -- ТЕСТ).
// - Постройка (Kind.Build): здание стоит полупрозрачным и не работает -- его скрипты выключены
// (не добывает, не даёт жильё и места, Казармы не учат). Тап по нему -- окно стройки.
// - Улучшение (Kind.Upgrade): здание работает на старом уровне, новый уровень -- когда время выйдет.
// - Одновременно идёт не больше Builders строек (строители). Ратуша -- тоже стройка.
// - Время -- реальное (GameClock.UnixNowMs): идёт и пока игра закрыта, и при перемотке DEV.
// - Офлайн (04.11): метка конца хранится в сохранении; после входа стройка завершается в тот момент расчёта
// добычи, когда время дошло до её конца (App/ConstructionClock) -- добыча здания идёт с момента завершения.
// - Сохраняет модуль Поселения вместе со зданием (startMs, endMs, вид, целевой уровень).
// Сколько длится стройка и сколько строителей -- задаёт слой приложения (EconomyConfig): модуль чисел не знает.
public class BuildTimer : MonoBehaviour
{
public enum Kind { Build = 1, Upgrade = 2 }
public static readonly List<BuildTimer> All = new List<BuildTimer>();
// Задаёт слой приложения (App/GameModules) из EconomyConfig
public static int Builders = 2;
// Можно ли завершить стройку, которая кончилась в этот момент (мс). Слой приложения отвечает "да", только когда
// расчёт добычи уже дошёл до этого момента (GameTimeSimulatedEvent): иначе после офлайна здание считалось бы
// готовым с самого начала офлайна. null -- завершать сразу.
public static Func<long, bool> MayComplete;
// Длительность в мс: (это Ратуша?, до какого уровня; 1 -- постройка). 0 -- мгновенно
public static Func<bool, int, long> DurationMs = (townHall, level) => 0;
public static int Busy => All.Count;
public static int FreeBuilders => Mathf.Max(0, Builders - Busy);
// Тап по зданию, которое строится (его собственные скрипты выключены) -- интерфейс откроет окно
public static event Action<BuildTimer> Tapped;
// Стройка закончилась: интерфейс пересчитает город и покажет сообщение
public static event Action<BuildTimer> Completed;
public Kind kind;
public long startMs;
public long endMs;
public int targetLevel = 1;
readonly List<MonoBehaviour> paused = new List<MonoBehaviour>();
readonly List<KeyValuePair<SpriteRenderer, Color>> tinted = new List<KeyValuePair<SpriteRenderer, Color>>();
bool finished;
Vector3 downPos;
public bool IsConstruction => kind == Kind.Build;
public double SecondsLeft => Math.Max(0, endMs - GameClock.UnixNowMs) / 1000.0;
public float Progress => endMs <= startMs ? 1f : Mathf.Clamp01((float)(GameClock.UnixNowMs - startMs) / (endMs - startMs));
public static BuildTimer Of(GameObject building) => building != null ? building.GetComponent<BuildTimer>() : null;
public static bool IsConstructing(Component building)
{
var t = building != null ? building.GetComponent<BuildTimer>() : null;
return t != null && !t.finished && t.kind == Kind.Build;
}
// Начать стройку или улучшение. Цену платит тот, кто вызывает. null -- уже идёт стройка этого здания
public static BuildTimer Begin(GameObject building, Kind kind, int targetLevel, long startMs, long endMs)
{
if (building == null) return null;
var old = building.GetComponent<BuildTimer>();
if (old != null && !old.finished) return null;
if (old != null) DestroyImmediate(old); // закончившийся, ещё не убранный Destroy
var t = building.AddComponent<BuildTimer>();
t.kind = kind;
t.targetLevel = Mathf.Max(1, targetLevel);
t.startMs = startMs;
t.endMs = Math.Max(startMs, endMs);
if (kind == Kind.Build) t.PauseBuilding();
if (!All.Contains(t)) All.Add(t);
return t;
}
// Закончить сейчас (ускорение за кристаллы или время вышло)
public void Complete()
{
if (finished) return;
finished = true;
All.Remove(this);
ResumeBuilding();
if (kind == Kind.Upgrade)
{
var townHall = GetComponent<TownHall>();
if (townHall != null)
{
// уровень только растёт: повторное или устаревшее завершение не понизит и не "докупит" уровни
townHall.currentLevel = Mathf.Min(townHall.maxLevel, Mathf.Max(townHall.currentLevel, targetLevel));
EventBus.Publish(new TownHallLevelChangedEvent { Level = townHall.currentLevel });
}
else
{
var leveled = GetComponent<ILeveledBuilding>();
if (leveled != null) leveled.Level = Mathf.Max(leveled.Level, targetLevel);
}
}
Completed?.Invoke(this);
Destroy(this);
}
// Убрать без результата (загрузка другого сохранения, новая игра)
public void Cancel()
{
finished = true;
All.Remove(this);
ResumeBuilding();
DestroyImmediate(this);
}
// Здание сносится вместе со стройкой: строитель свободен сразу, скрипты не включаем
public void Discard()
{
finished = true;
All.Remove(this);
}
void OnEnable()
{
if (!finished && !All.Contains(this)) All.Add(this);
}
void OnDestroy()
{
All.Remove(this);
}
void Update()
{
if (!finished && GameClock.UnixNowMs >= endMs && (MayComplete == null || MayComplete(endMs))) Complete();
}
// ---------------- Здание, которое строится, не работает ----------------
void PauseBuilding()
{
foreach (var mb in GetComponents<MonoBehaviour>())
{
if (mb == null || mb == this || !mb.enabled) continue;
if (mb is PlacedBuildingId || mb is BuildingSlotLink) continue;
mb.enabled = false;
paused.Add(mb);
}
foreach (var sr in GetComponentsInChildren<SpriteRenderer>(true))
{
tinted.Add(new KeyValuePair<SpriteRenderer, Color>(sr, sr.color));
var c = sr.color;
sr.color = new Color(c.r * 0.7f, c.g * 0.7f, c.b * 0.7f, c.a * 0.55f);
}
}
void ResumeBuilding()
{
foreach (var mb in paused) if (mb != null) mb.enabled = true;
paused.Clear();
foreach (var kv in tinted) if (kv.Key != null) kv.Key.color = kv.Value;
tinted.Clear();
}
// ---------------- Тап по зданию, которое строится ----------------
void OnMouseDown()
{
downPos = Input.mousePosition;
}
void OnMouseUpAsButton()
{
if (kind != Kind.Build || finished) return; // при улучшении окно открывает само здание
if (UIBlocker.IsPointerOverUI()) return;
if ((Input.mousePosition - downPos).magnitude > 25f) return;
Tapped?.Invoke(this);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dbfaba8641b05a9499eabe36c804fb22
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bd42162aabdf44f5bc5eff56e8ff9ca6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "Bogatyri.Settlement.Placement",
"rootNamespace": "",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5a79ac8eed504b9eb36740352ba25b73
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,249 @@
using System;
using System.Collections.Generic;
// РАЗМЕЩЕНИЕ ЗДАНИЙ В 3D-ПОСЕЛЕНИИ (ТЗ «Система строительства поселения» от 04.11.2026, этап I).
// Документ: Docs/Modules/Settlement.md, раздел «3D-поселение».
//
// Без Unity: только клетки, зоны, препятствия и занятость -- те же правила будут на сервере и в тестах.
// Отображение (местность, кубы-здания, призрак) -- в слое приложения (Assets/Scripts/City3D).
//
// Невидимая логическая сетка: клетка CellSize единиц мира. Здание занимает прямоугольник клеток
// (его максимальный габарит -- с первого уровня, апгрейд клетки не расширяет). Поворот 0/90/180/270.
// Числа -- ТЕСТ, предложение Саши на согласование (ТЗ, раздел 12).
namespace Bogatyri.Settlement.Placement
{
public enum CellKind
{
Ground, // обычная земля
Water, // река -- постоянное препятствие
Mountain // гора, скала -- постоянное препятствие
}
// Почему здание нельзя поставить
public enum PlaceError
{
None,
OutOfMap, // за краем карты
ZoneLocked, // зона ещё не открыта уровнем Ратуши
Water, // река
Mountain, // гора
Obstacle, // дерево или валун -- можно расчистить
Occupied, // другое здание
TooClose, // нарушен технический отступ до другого здания
TooSteep // слишком большой перепад высоты под зданием
}
public sealed class PlacementRules
{
public float CellSize = 1f; // размер клетки в единицах мира
public int GapCells = 1; // технический отступ между основаниями зданий, клеток
public int MaxStepMm = 700; // наибольший перепад высоты под зданием (мм); меньше -- фундамент, больше -- нельзя
public static PlacementRules Default => new PlacementRules();
}
// Здание на карте: прямоугольник клеток. X, Y -- левый нижний угол уже с учётом поворота
public struct PlacedRect
{
public string Id;
public string Type;
public int X, Y;
public int SizeW, SizeH; // габарит без поворота
public int Rotation; // 0, 90, 180, 270
public int W => Rotation == 90 || Rotation == 270 ? SizeH : SizeW;
public int H => Rotation == 90 || Rotation == 270 ? SizeW : SizeH;
}
public sealed class SettlementMap
{
public readonly int Width, Height;
public readonly PlacementRules Rules;
private readonly CellKind[] kind;
private readonly int[] heightMm; // высота земли в центре клетки, мм
private readonly int[] zoneLevel; // с какого уровня Ратуши клетка доступна для стройки (0 -- никогда)
private readonly Dictionary<int, string> obstacleAt = new Dictionary<int, string>(); // клетка -> id дерева/валуна
private readonly Dictionary<string, List<int>> obstacleCells = new Dictionary<string, List<int>>();
private readonly HashSet<string> cleared = new HashSet<string>();
private readonly Dictionary<string, PlacedRect> buildings = new Dictionary<string, PlacedRect>();
public SettlementMap(int width, int height, PlacementRules rules = null)
{
Width = Math.Max(1, width);
Height = Math.Max(1, height);
Rules = rules ?? PlacementRules.Default;
kind = new CellKind[Width * Height];
heightMm = new int[Width * Height];
zoneLevel = new int[Width * Height];
}
// ---------------- Описание карты (заполняет генератор местности) ----------------
public bool InMap(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
int Index(int x, int y) => y * Width + x;
public void SetCell(int x, int y, CellKind k, int heightMillimeters, int openAtTownHall)
{
if (!InMap(x, y)) return;
int i = Index(x, y);
kind[i] = k;
heightMm[i] = heightMillimeters;
zoneLevel[i] = Math.Max(0, openAtTownHall);
}
public CellKind KindAt(int x, int y) => InMap(x, y) ? kind[Index(x, y)] : CellKind.Mountain;
public int HeightAt(int x, int y) => InMap(x, y) ? heightMm[Index(x, y)] : 0;
public int ZoneAt(int x, int y) => InMap(x, y) ? zoneLevel[Index(x, y)] : 0;
public bool IsOpen(int x, int y, int townHallLevel) { int z = ZoneAt(x, y); return z > 0 && z <= townHallLevel; }
// Дерево или валун: занимает клетки, пока его не расчистили. id -- постоянный в пределах поселения
public void AddObstacle(string id, IEnumerable<(int x, int y)> cells)
{
if (string.IsNullOrEmpty(id) || obstacleCells.ContainsKey(id)) return;
var list = new List<int>();
foreach (var c in cells)
{
if (!InMap(c.x, c.y)) continue;
int i = Index(c.x, c.y);
if (obstacleAt.ContainsKey(i)) continue;
obstacleAt[i] = id;
list.Add(i);
}
obstacleCells[id] = list;
if (cleared.Contains(id)) foreach (int i in list) obstacleAt.Remove(i); // уже расчищен (из сохранения)
}
public string ObstacleAt(int x, int y) => InMap(x, y) && obstacleAt.TryGetValue(Index(x, y), out string id) ? id : null;
public bool IsCleared(string obstacleId) => cleared.Contains(obstacleId);
public IReadOnlyCollection<string> Cleared => cleared;
// Расчистка: один раз на объект. true -- только что расчищен (за это одна награда); false -- нет такого или уже
public bool ClearObstacle(string obstacleId)
{
if (string.IsNullOrEmpty(obstacleId) || !obstacleCells.TryGetValue(obstacleId, out var list) || cleared.Contains(obstacleId)) return false;
cleared.Add(obstacleId);
foreach (int i in list) obstacleAt.Remove(i);
return true;
}
// Расчищенные из сохранения -- до или после AddObstacle, порядок не важен
public void RestoreCleared(IEnumerable<string> ids)
{
if (ids == null) return;
foreach (var id in ids)
{
if (string.IsNullOrEmpty(id) || !cleared.Add(id)) continue;
if (obstacleCells.TryGetValue(id, out var list)) foreach (int i in list) obstacleAt.Remove(i);
}
}
// ---------------- Здания ----------------
public IReadOnlyCollection<PlacedRect> Buildings => buildings.Values;
public bool TryGetBuilding(string id, out PlacedRect rect) => buildings.TryGetValue(id ?? "", out rect);
public string BuildingAt(int x, int y)
{
foreach (var b in buildings.Values)
if (x >= b.X && y >= b.Y && x < b.X + b.W && y < b.Y + b.H) return b.Id;
return null;
}
// Проверить место: прямоугольник sizeW x sizeH с поворотом rotation, левый нижний угол (x, y).
// ignoreId -- здание, которое переносят: его прежний след не мешает. foundationMm -- высота фундамента
public PlaceError Validate(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm)
{
foundationMm = 0;
rotation = NormalizeRotation(rotation);
int w = rotation == 90 || rotation == 270 ? sizeH : sizeW;
int h = rotation == 90 || rotation == 270 ? sizeW : sizeH;
if (w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > Width || y + h > Height) return PlaceError.OutOfMap;
int min = int.MaxValue, max = int.MinValue;
for (int cy = y; cy < y + h; cy++)
for (int cx = x; cx < x + w; cx++)
{
int i = Index(cx, cy);
if (kind[i] == CellKind.Water) return PlaceError.Water;
if (kind[i] == CellKind.Mountain) return PlaceError.Mountain;
if (!IsOpen(cx, cy, townHallLevel)) return PlaceError.ZoneLocked;
if (obstacleAt.ContainsKey(i)) return PlaceError.Obstacle;
min = Math.Min(min, heightMm[i]);
max = Math.Max(max, heightMm[i]);
}
int gap = Math.Max(0, Rules.GapCells);
foreach (var b in buildings.Values)
{
if (b.Id == ignoreId) continue;
if (Overlap(x, y, w, h, b.X, b.Y, b.W, b.H)) return PlaceError.Occupied;
if (Overlap(x - gap, y - gap, w + 2 * gap, h + 2 * gap, b.X, b.Y, b.W, b.H)) return PlaceError.TooClose;
}
if (max - min > Rules.MaxStepMm) return PlaceError.TooSteep;
foundationMm = max - min; // небольшой перепад -- площадка под зданием (верх -- по самой высокой клетке)
return PlaceError.None;
}
// Поставить новое здание. Место проверяется теми же правилами; false -- ничего не изменилось
public PlaceError Place(string id, string type, int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel)
{
if (string.IsNullOrEmpty(id) || buildings.ContainsKey(id)) return PlaceError.Occupied;
PlaceError e = Validate(x, y, sizeW, sizeH, rotation, townHallLevel, null, out _);
if (e != PlaceError.None) return e;
buildings[id] = new PlacedRect { Id = id, Type = type, X = x, Y = y, SizeW = sizeW, SizeH = sizeH, Rotation = NormalizeRotation(rotation) };
return PlaceError.None;
}
// Перенос (ТЗ, раздел 5): то же здание с тем же id -- меняются только позиция и поворот, целиком или никак.
// Свой прежний след не мешает. Ошибка -- здание остаётся, где было
public PlaceError Move(string id, int x, int y, int rotation, int townHallLevel)
{
if (!buildings.TryGetValue(id ?? "", out PlacedRect b)) return PlaceError.OutOfMap;
PlaceError e = Validate(x, y, b.SizeW, b.SizeH, rotation, townHallLevel, id, out _);
if (e != PlaceError.None) return e;
b.X = x; b.Y = y; b.Rotation = NormalizeRotation(rotation);
buildings[id] = b;
return PlaceError.None;
}
// Восстановление из сохранения: без проверки зон (зона могла быть открыта раньше), но без наложений
public bool Restore(PlacedRect rect)
{
if (string.IsNullOrEmpty(rect.Id) || buildings.ContainsKey(rect.Id)) return false;
rect.Rotation = NormalizeRotation(rect.Rotation);
foreach (var b in buildings.Values)
if (Overlap(rect.X, rect.Y, rect.W, rect.H, b.X, b.Y, b.W, b.H)) return false;
buildings[rect.Id] = rect;
return true;
}
public static int NormalizeRotation(int rotation)
{
int r = ((rotation % 360) + 360) % 360;
return r / 90 * 90;
}
static bool Overlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh)
{
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
}
public static string ErrorText(PlaceError e)
{
switch (e)
{
case PlaceError.None: return "Можно ставить";
case PlaceError.OutOfMap: return "За краем карты";
case PlaceError.ZoneLocked: return "Зона не открыта: нужен уровень Ратуши выше";
case PlaceError.Water: return "Здесь река";
case PlaceError.Mountain: return "Здесь скалы";
case PlaceError.Obstacle: return "Мешает дерево или валун -- расчистите";
case PlaceError.Occupied: return "Место занято другим зданием";
case PlaceError.TooClose: return "Слишком близко к другому зданию";
case PlaceError.TooSteep: return "Слишком крутой склон";
default: return e.ToString();
}
}
}
}

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