惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Один простой механизм управляет практически всем в игре Cities: Skylines
PatientZero · 2026-05-28 · via Все публикации подряд на Хабре

16 мин

17K

Мне захотелось узнать, как игра Cities: Skylines обеспечивает постоянное движение, которое мы видим в растущем городе — жители ищут работу, туристы посещают достопримечательности, мусоровозы ездят по своим маршрутам, люди находят себе пары, но не смог найти почти никакой информации. Поэтому я декомпилировал игру и решил разобраться сам. Выяснилось, что почти все взаимодействия в игре выполняются через простую, изящную систему: торги, напоминающие фондовый рынок.

The fictional city of Cannburg

Основы

На этом упрощённом фондовом рынке есть предложения «покупки» и «продажи», которые связывают посредники. Когда вы видите в Cities: Skylines человека или транспорт, перемещающийся из одной точки в другую, часто это связано с тем, что через TransferManager (наш «рынок») было передано предложение TransferOffer. Здания, транспорт и города могут создавать предложения «покупки»/«продажи» определённых вещей, а когда находится соответствие с другой стороны, результаты доставляются или собираются.

Предложения добавляются с TransferReason, приоритетом и количеством. Приоритет используется для ранжирования предложений. TransferReason может быть чем-то осязаемым наподобие Oil или Coal для промышленных зданий, но может быть и Fire для горящего здания, Crime, если требуется полиция, или даже Partner, когда сим ищет свою половинку. Существует и множество других типов: катафалки, сбор мусора, школы и шоппинг.

Информация для этого поста была получена при реверс-инжиниринге игры Cities: Skylines и чтении её декомпилированного кода. Фрагменты кода очень близки к тем, которые были выпущены в игре; для ясности я переименовал и удалил часть кода. Если у вас есть эта игра и вы хотите изучить её самостоятельно, то хорошее введение можно найти в Cities: Skylines Modding Guide.

Избавляемся от мусора

Чтобы понять эту систему трансферов в общих чертах, давайте рассмотрим, как устроен сбор мусора.

У класса CommonBuildingAI есть метод HandleCommonConsumption, вызываемый из SimulationStep здания; он контролирует электричество, мусор, потребление воды и так далее. Мусор постепенно накапливается в m_garbageBuffer здания (скорость накопления — это просто число, получаемое, исходя из типа здания, уровня, правил района и некоторых других факторов).

На каждом шаге симуляции выполняется проверка: если буфер достиг значения не менее 200 и на пятигранном кубике выпадет нужное число и игрок разблокировал сбор мусора, то здание решает выставить мусор для сбора.

Garbage waiting for collection

Мусор, ожидающий сбора

Перед этим оно просматривает свои гостевые транспортные средства (m_guestVehicles) и добавляет свободное место для каждого, которое уже едет, чтобы собрать TransferReason.Garbage, то есть для мусоровозов, которые уже находятся на маршруте, но пока не добрались до здания. Это входящий объём вычитается из буфера, и только если собрать по-прежнему нужно не меньше 200, здание добавляет «предложение о продаже» при помощи AddOutgoingOffer (здание «продаёт» свой мусор):

Singleton<TransferManager>.instance.AddOutgoingOffer(
      TransferManager.TransferReason.Garbage,
      new TransferManager.TransferOffer() {
            Priority = remaining / 1000,
            Building = buildingID,
            Position = data.m_position,
            Amount = 1
      }
);

Всегда запрашивается только одна «единица» (поэтому и Amount равно 1), но эта 1 не эквивалентна одной единице мусораAmount — это количество транзакций: оно означает «найти мне один сбор мусора», то есть «отправь один грузовик». Количество забираемого мусора отдельно вычисляется позже — когда грузовик прибывает, он загружает в своё пространство то, количество, которое находится в m_garbageBuffer. Поэтому если у одного здания есть 200 мусора, а другое утопает в 60000, то оба всё равно публикуют одно и то же предложение; каждое из них получает один грузовик, и этот грузовик берёт столько, сколько может везти. Если мусор остался, то на следующем шаге буфер всё ещё больше порогового значения, поэтому здание добавляет ещё одно предложение Amount = 1 и к нему приезжает ещё один грузовик. Большой бэклог расчищается в течение множества шагов; один грузовик не убирает весь мусор за одну героическую перевозку.

Масштаб проблемы выражается не в Amount; он скрыт в Priority (remaining / 1000), поэтому утопающее в мусоре здание предлагает более высокую цену, чем здание с малым количеством мусора, выигрывая приезд грузовика в большем количестве шагов. Запрашивание всего одного грузовика, раз за разом, эквивалентно описанному выше вычитанию прибывающих грузовиков. С этой темой мы нагляднее познакомимся ниже, когда будем говорить о свалках.

Поиск мусора для сбора

Итак, мусор выставлен на продажу; нам осталось найти покупателя. В этой покупке заинтересована городская свалка.

Cannburg Garbage Facility

ИИ этого «здания» находится в LandfillSiteAI. Его метод ProduceGoods (вызываемый из PlayerBuildingAI.SimulationStep) начинает с поиска среди собственных транспортных средств (m_ownVehicles) и подсчёта тех из них, которые уже перевозят TransferReason.Garbage. Стоит отметить, что он проверяет здесь собственные машины, а не гостевые, как здание из примера выше, потому что эти «гостевые» транспортные средства, которые видит здание, на самом деле принадлежат этой свалке; они уже распределены и находятся на маршруте по сбору мусора.

После выполнения логики, связанной с оставшейся свободной площадью для заполнения мусором, свалка переходит в состояние получения нового мусора, который она добавляет, как исходящее TransferOffer:

Singleton<TransferManager>.instance.AddIncomingOffer(
      TransferManager.TransferReason.Garbage,
      new TransferManager.TransferOffer() {
            Priority = 2 - ownedGarbageTruckCount,
            Building = buildingID,
            Position = buildingData.m_position,
            Amount = 1,
            Active = true
      }
);

Приоритет вычисляется как 2 - ownedGarbageTruckCount, то есть чем больше грузовиков уже выпустила свалка на сбор мусора, тем меньше приоритет запроса. Если на маршруте находится уже два мусоровоза, он снижается до 0. Он не может быть меньше 0TransferOffer.Priority — это не простое поле, а целочисленное свойство, упакованное в четыре бита флагов. Его сеттер выполняет Mathf.Clamp(value, 0, 7), поэтому -1 преобразуется в 0 в момент присвоения значения (аналогично, приоритет больше 7 урезается до 7).

Почему система спроектирована таким образом? Самое важное здесь Active = true для входящего предложения. Как мы увидим, когда доберёмся до алгоритм поиска соответствий, активная сторона соответствия — это та, которая действует; а здесь именно нахождение соответствия для этого предложения заставляет свалку физически вывести грузовик на маршрут. Поэтому благодаря 2 - ownedGarbageTruckCount свалка ограничивает себя. Каждый выпущенный ею на маршрут грузовик заставляет её снижать приоритет ставок для следующей задачи.

Почему полезно, что свалка намеренно снижает приоритет, когда она занята? Это станет понятно, когда мы узнаем, как выполняется поиск соответствий предложений. Вкратце: это позволяет простаивающим свалкам выигрывать ставки у сильно занятых, распространяя сбор мусора по всем свалкам, а не только по ближайшим.

Как хранятся предложения

Класс TransferManager хранит входящие и исходящие предложения отдельно, но структуры их идентичны. Для каждого описанного здесь массива m_outgoing*  существует зеркальный m_incoming*. В этом разделе мы поговорим об исходящих предложениях.

Все исходящие предложения (для каждого TransferReason) хранятся в едином огромном массиве TransferOffer[] m_outgoingOffers из 262144 элементов — 128 слотов причин × 8 уровней приоритета × 256 предложений на блок. По сути, они разбиты на «блоки» по 256, где каждый представляет каждую комбинацию TransferReason и приоритета. То есть может быть до 256 предложений для TransferReason.Garbage с приоритетом 0; все они хранятся вместе, сразу за ними идёт блок Garbage с приоритетом 1 и так далее.

[
      /* [block 0] 0 - 255: TransferReason.Garbage, Priority 0 */,
      /* [block 1] 256 - 511: TransferReason.Garbage, Priority 1 */,
      /* [block 2] 512 - 767: TransferReason.Garbage, Priority 2 */,
      /* ... */
      /* [block 805] 206,080 - 206,335: TransferReason.PlanedTimber, Priority 5 */,
      /* [block 806] 206,336 - 206,591: TransferReason.PlanedTimber, Priority 6 */,
      /* ... */
]

Этот паттерн повторяется для всех причин (TransferReason) и приоритетов. Номер блока равен (int) (reason * 8) + priority. В массиве есть место для 128 причин (от 0 до 127).

Каждый из этих 262144 слотов намеренно сделан маленьким, благодаря чему возможно работать с массивом такого большого размера. По сути, TransferOffer — это одно 32-битное integer с дескриптором экземпляра и байтом: Active, Exclude и Unlimited по одному биту каждый, плюс четвёртый флаговый бит (FLAG_LARGE_POS), определяющий способ декодирования позиции (подробнее об этом в разделе «Близко — это насколько близко?»); Priority — это 4-битное поле (как мы видели ранее, ограниченное интервалом 0-7), Amount — это 8-битное поле (ограниченное интервалом 0-255), а позиции по X и Z занимают по одному байту (к этому мы тоже вернёмся в разделе «Близко — это насколько близко?»). Итого получается 32 бит: 4 флага, 4 бита для приоритета, 8 для amount, по 8 для X и Z.

Так как оба массива полностью распределяются заранее, полностью насыщенный рынок — все 128 причин × 8 приоритетов × 256 слотов для входящих и исходящих сторон, все 524288 предложений сразу — занимают не больше памяти, чем пустой: 2 × 262144 × 12 байт, то есть примерно 6 МБ (сырые поля имеют размер всего 9 байт, но выравнивание округляет каждый слот до 12 байт). Для системы, которая управляет почти каждым взаимодействием в игре, это почти что ничего.

Предложения отслеживают ещё два массива, но стоит отметить, что они не индексируются одинаково:

  • ushort[] m_outgoingCount (размер 1024 = 128 причин × 8 приоритетов) хранит текущее количество предложений для каждого блока (reason, priority). Также он служит в качестве смещения для вставки: указывает на следующий свободный слот в этом блоке.

  • int[] m_outgoingAmount (размер 128) содержит единственную скользящую сумму на каждую reason, полученную для всех 8 уровней приоритетов. Он управляется при добавлении/удалении и сериализуется для сохранения файлов, однако его не читает никакой код. Похоже, это рудиментарный код, вероятнее всего, оставшийся от вырезанной фичи или телеметрии.

Если есть место, предложение вставляется согласно его собственному приоритету; в противном случае система обходит более низкие приоритеты в поисках места для него. Если каждый блок из запрошенного приоритета вплоть до 0 заполнен (по 256 в каждом), то цикл завершается, а предложение просто отклоняется.

public void AddOutgoingOffer(TransferReason reason, TransferOffer offer) {
  for (int priority = offer.Priority; priority >= 0; --priority) {
    int offerBlock = (int) (reason * 8) + priority;
    int offset = (int) this.m_outgoingCount[offerBlock];

    if (offset < 256) {
      this.m_outgoingOffers[(offerBlock * 256) + offset] = offer;
      this.m_outgoingCount[offerBlock] = (ushort) (offset + 1);
      this.m_outgoingAmount[(int) reason] += offer.Amount;
      break;
    }
  }
}

Поиск соответствий предложений

И входящие, и исходящие предложения переданы классу TransferManager, так что пришло время разобраться, как происходит поиск соответствий между ними и как обеспечивается сбор мусора. На каждом шаге симуляции менеджер сопоставляет все предложения для всего одной TransferReason. Какой именно, определяется currentFrameIndex & 0xFF, передаваемого через GetFrameReason  — фиксированный 256-кадровый график, где только определённые слоты сопоставлены с причиной, а остальные ресолвятся в None и ничего не делают. То есть каждая причина получает свой ход ровно раз в 256 кадров симуляции в собственном фиксированном слоте (Garbage находится по индексу 3, Crime по индексу 67 и так далее). Это добавляет поиску соответствий внутреннюю задержку: только что добавленное в список предложение может прождать целый цикл, прежде чем дойдёт очередь до её причины, как бы ни было близко идеальное соответствие.

Сначала вычисляется оптимальное расстояние для конкретной TransferReason. У каждой причины есть конкретное оптимальное расстояние, и предложение выбирается в пределах этого расстояния. См. подробности в разделе «Близко — это насколько близко?».

Начиная с приоритета p со значением 7 и опускаясь до 0, предложения сопоставляются по блокам. В каждом блоке сначала находится соответствие первому входящему предложению, потом первому исходящему, затем второму входящему и так далее, пока не будут найдены соответствия для всех предложений в этом блоке. Потом уровень приоритета снижается на один и происходит сопоставление следующего блока; этот процесс повторяется до самого нижнего уровня приоритетов, пока не будут найдены соответствия для всех предложений конкретной TransferReason.

Как и в предыдущем разделе, сопоставление предложений выполняется раздельно для входящих и исходящих, но на самом деле, это один и тот же процесс, только в противоположном порядке, поэтому я покажу поток только для исходящих.

Исходящее предложение выбирается по индексу блока и значению i, которое используется для итеративного обхода этого блока . Затем по формуле Mathf.Max(0, 2 - p) вычисляется нижняя граница приоритета. Предложение с приоритетом 7 ищет всех партнёров везде, вплоть до приоритета 0; предложение с приоритетом 2 выполняет поиск до 0; предложение с приоритетом 1 ищет только на собственном уровне. Любопытна ситуация с приоритетом 0: его граница равна 2, а цикл, выполняющий обход сверху с 0 до 2, не выполняется ни разу, поэтому предложение с приоритетом 0 никогда не инициирует поиск соответствия. Найти ему соответствие можно только пассивно, когда входящее предложение с приоритетом 2 или выше спускается и выбирает его. Чем выше приоритет, тем больше для него кандидатов; у самого нижнего вообще нет вариантов и он просто ждёт сам, пока его выберут.

Это вторая часть описанной выше системы свалок. Свалка, выпустившая на дороги достаточное количество грузовиков, чтобы опустить входящие предложения до приоритета 0, полностью прекращают активную работу: они больше не обращаются сами для поиска соответствий по предложениям мусора. Они снова выполняют сбор только тогда, когда куча мусора здания стала достаточно большой для повышения её приоритета предложения настолько, чтобы начать искать свалку. Так что 2 - ownedGarbageTruckCount не просто снижает шансы сильно занятой свалки; когда это значение доходит до нуля, свалка перестаёт быть инициатором предложений, благодаря чему следующий сбор мусора передаётся простаивающей свалке.

TransferOffer outgoingOffer = m_outgoingOffers[(offerBlock * 256) + i];
int lowerPriorityBound = Mathf.Max(0, 2 - p);

Предложение с флагом Exclude усиливает это ограничение до Mathf.Max(0, 3 - p), отказываясь находить соответствующие пары ниже него: игра использует это, например, для того, чтобы трафик снаружи карты перестал впитывать спрос, который должен обслуживать локальный поставщик.

Далее система начинает с уровня приоритета p и продвигается вниз до этой нижней границы. Для каждого значения p система затем обходит соответствующий блок предложений (пропуская в этом блоке предложения, для которых уже найдены соответствия). Для каждого предложения (не исходящего из одного и того же места) при помощи SqrMagnitude вычисляется расстояние, после чего вычисляется distanceValue. Если значение больше bestDistanceValue, то это предложение более оптимально. Если предложение изначально имеет расстояние меньше optimalDistance, то для этого приоритета больше не рассматриваются никакие входящие предложения, а цикл перемещается вниз, к следующему приоритету.

int bestPriority = -1, bestOfferIndex = -1;
float bestDistanceValue = -1f;
for (int pOther = p; pOther >= lowerPriorityBound; pOther--) {
    int otherBlock = (int)reason * 8 + pOther;
    int blockCount = m_incomingCount[otherBlock];

    float pOther2 = (float)pOther + 0.1f;

    // используется для того, чтобы дать высоким приоритетам большую вероятность найти соответствие
    if (bestDistanceValue >= pOther2) {
        break;
    }

    // начинается с `incomingIndex`, потому что на этом этапе в блоке мы уже сопоставили
    // некоторые из входящих предложений, так что их нужно пропустить
    for (int otherIndex = incomingIndex; otherIndex < blockCount; otherIndex++) {
        TransferOffer other = m_incomingOffers[otherBlock * 256 + otherIndex];

        if (other.m_object == outgoingOffer.m_object) {
            continue; 
        }

        float offerDistance = Vector3.SqrMagnitude(other.Position - outgoingOffer.position);
        float distanceValue = 
            distanceMultiplier >= 0.0
            ? (pOther2 / (1f + offerDistance * distanceMultiplier))
            : (pOther2 - pOther2 / (1f - offerDistance * distanceMultiplier));

        if (distanceValue > bestDistanceValue) {
            bestPriority = pOther;
            bestOfferIndex = otherIndex;
            bestDistanceValue = distanceValue;

            // Если предложение находится в пределах допустимого расстояния, сохраняем его 
            // и переходим к следующему приоритету
            if (offerDistance < optimalDistance) {
                break;
            }
        }
    }
}

После нахождения предложения вычисляется величина приобретения (Mathf.Min(outgoingOffer.Amount, incomingOffer.Amount)) и для этой пары предложений вызывается StartTransfer. Затем этот цикл продолжается до полного выполнения исходящего предложения (или пока не завершатся соответствующие входящие предложения). Далее система переходит к следующему предложению.

Величина равна Min(out, in) только в стандартном случае: если у обоих предложений установлен флаг Unlimited, то величина вычисляется как Max(out, in). Именно так, по сути, неограниченные конечные точки (внешние соединения) перемещают за одно соответствие большое количество вместо того, чтобы уменьшать его постепенно.

Отправка грузовика

Далее метод StartTransfer в TransferManager инициирует трансфер между парами предложений. Для обработки трансфера в паре выбирается одно транспортное средство, житель или здание. Если в одном из предложений есть транспортное средство (Vehicle), то используется оно (отдаётся предпочтение входящему, а не исходящему предложению); в противном случае, если в предложении есть житель (Citizen), то используется он (снова выбирается входящее предложение). И в конце используется Building; в этом случае выбор действующей стороны зависит от того, у какого предложения установлен флаг Active (см. ниже).

private void StartTransfer(TransferReason reason, TransferOffer offerOut, TransferOffer offerIn, int delta) {
  if (offerIn.Active && offerIn.Vehicle != 0) {
    // получаем транспортное средство из входящего предложения и выполняем трансфер
    Array16<Vehicle> vehicles = Singleton<VehicleManager>.instance.m_vehicles;
    VehicleInfo info = vehicles.m_buffer[(int) offerIn.Vehicle].Info;
    info.m_vehicleAI.StartTransfer(offerIn.Vehicle, ref vehicles.m_buffer[(int) offerIn.Vehicle], reason, offerOut);
  }
  else if (offerOut.Active && offerOut.Vehicle != 0) { /* получаем транспорт из исходящего предложения и выполняем трансфер */ }
  else if (offerIn.Active && offerIn.Citizen != 0) { /* получаем жителя из входящего предложения и выполняем трансфер */ }
  else if (offerOut.Active && offerOut.Citizen != 0) { /* получаем жителя из исходящего предложения и выполняем трансфер */ }
  else if (offerOut.Active && offerOut.Building != 0) { /* получаем здание из исходящего предложения и выполняем трансфер */ }
  else if (offerIn.Active && offerIn.Building != 0) { /* получаем здание из входящего предложения и выполняем трансфер */ }
}

Каждое ветвление запускается со стороны предложения с установленным флагом Active: активная сторона — это та, которая действует физически (например, высылает грузовик). Порядок «сначала входящие, потом исходящие» (для транспорта и жителей) применяется только в случае ничьей, когда активны обе стороны; для зданий порядок меняется на «сначала исходящие». В нашем примере с мусором активно только предложение свалки (Active = true из раздела «Поиск мусора для сбора»), поэтому исходящее предложение здания пропускается, а поиск соответствий проваливается вниз до входящего предложения свалки.

Затем этот алгоритм поиска соответствий вызывает LandfillSiteAI.StartTransfer, который ищет транспортное средство из VehicleManager и вызывает GarbageTruckAI.StartTransfer (GarbageTruckAI наследует CarAI, который наследует VehicleAI).

Последние два параметра CreateVehicle устанавливают флаги TransferToSource и TransferToTarget — флаг TransferToSource применяется здесь для привоза мусора на свалку.

bool vehicleCreated = Singleton<VehicleManager>.instance.CreateVehicle(
  out vehicle,
  ref Singleton<SimulationManager>.instance.m_randomizer,
  randomVehicleInfo, data.m_position, reason, true, false
);

if (vehicleCreated) {
  randomVehicleInfo.m_vehicleAI.SetSource(vehicle, ref vehicles.m_buffer[(int) vehicle], buildingID);
  randomVehicleInfo.m_vehicleAI.StartTransfer(vehicle, ref vehicles.m_buffer[(int) vehicle], reason, offer);
  break;
}
A truck leaving the landfill site

Грузовик выезжает со свалки

Теперь на свалке будет создан мусоровоз, который поедет в сторону здания, чтобы собрать мусор.

Сбор мусора и возвращение

Теперь грузовик спокойно едет к зданию. CarAI (базовый класс GarbageTruckAI) вызывает this.ArriveAtDestination (виртуальный в VehicleAI и реализованный в GarbageTruckAI) из своего SimulationStepArriveAtDestination, а затем вызывает ArriveAtTarget (или ArriveAtSource, но здесь мы едем к цели (target), а не к исходной точке (source)).

Arriving at the house

Затем грузовик вычисляет, сколько мусора ему нужно собрать (также он может сбросить мусор в цели, если transferToTarget, то есть последний параметр CreateVehicle, был равен true) и использует BuildingManager для снижения количества мусора в здании. Он вычисляет, сколько мусора теперь везёт, а также сбрасывает свою цель.

BuildingAI building = buildingManager.m_buildings.m_buffer[(int) data.m_targetBuilding].Info.m_buildingAI
building.ModifyMaterialBuffer(
  data.m_targetBuilding,
  ref instance.m_buildings.m_buffer[(int) data.m_targetBuilding],
  (TransferManager.TransferReason) data.m_transferType,
  ref amountDelta
);

if (transferringToTarget) {
  data.m_transferSize = (ushort) Mathf.Clamp((int) data.m_transferSize - amountDelta, 0, (int) data.m_transferSize);
}

this.SetTarget(vehicleID, ref data, (ushort) 0);

SetTarget проверяет, установлен ли у автомобиля флаг TransferToSource, и если это так, то устанавливает флаг GoingBack. Затем вызывается метод StartPathFind, который проверяет флаг GoingBack и начинает путь обратно на свалку.

Когда грузовик прибывает на свалку, вызывается ArriveAtSource, который проверяет флаг TransferToSource и снова использует BuildingManager для трансфера мусорного материала из грузовика на свалку.

Затем машина освобождается и это становится концом её маршрута. Мусор собран и вывезен на свалку!

Близко — это насколько близко?

Оптимальное расстояние для конкретной TransferReason вычисляется так:

float distanceMultiplier = TransferManager.GetDistanceMultiplier(reason);
float optimalDistance = (double) distanceMultiplier == 0.0 ? 0.0f : 0.01f / distanceMultiplier;

Как мы видели в цикле поиска соответствий, когда кандидат оказывается лучшим из встреченных пока и он ближе, чем optimalDistance, поиск прерывается и пара предложений выбирается. Это не чёткая отсечка, а пороговое значение «достаточно хорошего» выбора — поиск более далёких соответствий всё равно происходит, но только если нет ничего более близкого.

Показанная выше формула возвращает не расстояние, а квадрат расстояния. Ниже показан график его квадратных корней, то есть реальный радиус в игровых единицах длины:

Matching radius by TransferReason

Радиус ещё приблизительнее, чем кажется, потому что Position предложения хранится не в точном виде. Она дискретизируется до одного байта на ось (см. информацию о битовой упаковке в разделе «Как хранятся предложения») в сетке разрешением 37,5 единицы. Эта сетка в 37,5 единицы покрывает весь мир ванильной игры размером 5×5 тайлов (256 ячеек × 37,5 = 9600 единиц ±4800 от точки начала координат), однако рельеф покрывает площадь в 9×9 тайлов (±8640). Существует вторая, более грубая сетка: после ±4800 единиц флаговый бит меняет X/Z to на сетку разрешением 270 единиц, охватывающую весь рельеф, поэтому предложения внешних соединений (располагаемые по краям рельефа) дискретизируются с примерно в семь раз меньшей точностью, чем предложения внутри города.

Это очень влияет на аварийно-спасательные службы, действующие на коротких расстояниях. CrimeDead и Fire имеют «достаточно хороший» радиус примерно в 32 единиц, что меньше одной ячейки размером 37,5 единицы. При таких расстояниях преждевременный выход из цикла, по сути, означает ту же или соседнюю ячейку, а более мелкое разрешение остаётся невидимым для алгоритма поиска соответствий. Именно поэтому когда множество станций находятся на расстоянии примерно одной ячейки друг от друга, то высланная полицейская машина, катафалк или пожарный наряд необязательно выедет с ближайшей станции. При таком разрешении алгоритм не может различать их, поэтому выбирает первую достаточно хорошую, которую найдёт.

Заключение

Одно из моих любимых занятий в Cities: Skylines после того, как город немного разрастётся — наблюдать, как жители города ведут свою повседневную жизнь. Поразительно видеть всё творящееся разнообразие — промышленные здания поставляют ресурсы другим промышленным зданиям, те отправляют товары в магазины, потом в эти магазины приходят люди, а туристы бродят по городским достопримечательностям. Благодаря естественности их маршрутов и действий игра всегда остаётся свежей и восхитительной.

После того, как я узнал, как всё это работает изнутри, моё восхищение лишь возросло. Почти все эти взаимодействия пропускаются через один и тот же простой примитив: типизированное предложение, приоритет и один алгоритм поиска соответствий. В системе есть место для 128 TransferReason, а в текущей версии игры определено 123: 61 причина выпущена в базовой игре, а 62 добавлены в разных DLC.

Это дало мне и более важный урок. Достаточно одной тщательно спроектированной абстракции: вложись в неё один раз, а потом получай совокупную выгоду, добавляя поверх неё новый геймплей, расширения и контент. К подобной структуре стремятся многие, но создать её сложно.

Как бы то ни было, я продолжу запускать Cities: Skylines, только теперь с ещё большим уважением к той сложности, которая таится в процессе доставки мусора от дома на свалку.

Благодарности

Разумеется, я благодарю Colossal Order за создание игры настолько заманчивой, что я просто обязан был разобраться, как она работает. Также спасибо моду MoreEffectiveTransfer разработчика pcfantasy за то, что с него было проще приступить к анализу всего этого.