Мне захотелось узнать, как игра 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 за то, что с него было проще приступить к анализу всего этого.

Комментарии (1)