Фараон, вышедший в далеком 1999 году был одной из первых игр, которые предлагали поэтапное строительство зданий. Да которые еще и требовали наличие разных ресурсов. Навскидку могу припомнить серию Settlers, Majesty и может еще парочку. После Цезарь III, исхоженного вдоль и поперек, где основным ресурсом при постройке зданий были монеты, это было действительно удивительно и ново. Особым удовольствием было наблюдать, как город живет своей жизнью в процессе постройки монументов, помню просто построил минимальную необходимую инфраструктуру для монумента и просто наблюдал как архитекторы жаловались на недостаток материалов, рабы бегали то на фермы, то на строительную площадку, торговцы периодически продавали кирпичи. Ну и остальной город, конечно, жил своей жизнью, можно даже забыть на какое время про отдельные части города, игра все равно не остановится, и тут понимаешь почему игра до сих пор остается одним из лучших градостроев: отличительная особенность серии — «баланс», баланс отточенный в мелочах. Восстанавливая эту часть игры, я не перестаю удивляться как это все было реализовано на тех аппаратных средствах, замечу, очень и очень небольших, 64Мб оперативки было далеко не у всех. Одним из нововведений монументов было то, что они были составными зданиями, отдельные части могли быть заменены на другие, что в общем давало возможность из одного набора текстур создавать разные по виду здания. Это сейчас кажется, что такой подход есть в любой игре, но в 99 такой механикой могли похвастаться немногие. Сначала я пытался восстановить оригинальный алгоритм отрисовки, но быстро понял, что столько if не осиливаю не только я, но и компилятор, пришлось велосипедить


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

Количество людей в домах зависит от текущего уровня дома, каждый уровень требует новый вид «ресурса» для поддержки, причем это не обязательно должны быть продукты и товары, доступность к храмам и услугам, вроде аптеки тоже нужен для поддержания уровня дома. И если для начальных уровней домов хватает одного рынка на пару десятков домов, то после середины становится заметно, что количество зданий которые может поддерживать рынок балансили специально. И хотя в обзорах об этом нигде не говорили, но игроки вычислили что один рынок может поддерживать максимум 4 особняка максимального уровня, при этом два рынка не обслуживают больше, даже если их области работы перекрываются. Сама пирамида потребностей без изменений перекочевала из прошлой игры серии, она так удачно легла на весь сеттинг серии, что её не меняли вплоть до Emperor: Rise of the Kingdom.

Как строили отцы

Первый монумент, доступный игроку — это мастаба. В оригинале он выглядит вот так в начале постройки (скриншот из оригинальной игры):

И вот так по завершению (скриншот из оригинальной игры):

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

Код отрисовки всей мастабы за один проход из оригинала (восстановлено частично)
void draw_mastaba_base_tiles(int type, int x, int y, int orientation) {
    int flooring_image_id = image_id_from_group(GROUP_BUILDING_MASTABA_FLOORING, type);
    int side1_image_id = image_id_from_group(GROUP_BUILDING_MASTABA_side_1, type);
    int side2_image_id = image_id_from_group(GROUP_BUILDING_MASTABA_side_2, type);

    int EMPTY = 0;

    // floor tiles
    int til_0 = flooring_image_id + 0;
    int til_1 = flooring_image_id + 1;
    int til_2 = flooring_image_id + 2;
    int til_3 = flooring_image_id + 3;

    // small (1x1) sides
    int smst0 = side1_image_id + (4 - city_view_orientation() / 2) % 4; // north
    int smst1 = side1_image_id + (5 - city_view_orientation() / 2) % 4; // east
    int smst2 = side1_image_id + (6 - city_view_orientation() / 2) % 4; // south
    int smst3 = side1_image_id + (7 - city_view_orientation() / 2) % 4; // west

    // long (1x2) sides
    int lst0B = side2_image_id + (8 - city_view_orientation()) % 8; // north
    int lst0A = side2_image_id + (9 - city_view_orientation()) % 8;
    int lst1B = side2_image_id + (10 - city_view_orientation()) % 8; // east
    int lst1A = side2_image_id + (11 - city_view_orientation()) % 8;
    int lst2B = side2_image_id + (12 - city_view_orientation()) % 8; // south
    int lst2A = side2_image_id + (13 - city_view_orientation()) % 8;
    int lst3B = side2_image_id + (14 - city_view_orientation()) % 8; // west
    int lst3A = side2_image_id + (15 - city_view_orientation()) % 8;

    // correct long sides graphics for relative orientation
    switch (city_view_orientation() / 2) {
    case 1:
    case 0:
        lst1A = side2_image_id + (10 - city_view_orientation()) % 8; // east
        lst1B = side2_image_id + (11 - city_view_orientation()) % 8;
        lst3A = side2_image_id + (14 - city_view_orientation()) % 8; // west
        lst3B = side2_image_id + (15 - city_view_orientation()) % 8;
        break;
    }
    switch (city_view_orientation() / 2) {
    case 3:
    case 0:
        lst0A = side2_image_id + (8 - city_view_orientation()) % 8; // north
        lst0B = side2_image_id + (9 - city_view_orientation()) % 8;
        lst2A = side2_image_id + (12 - city_view_orientation()) % 8; // south
        lst2B = side2_image_id + (13 - city_view_orientation()) % 8;
        break;
    }

    // adjust northern tile offset
    tile2i north_tile = {x, y};
    switch (orientation) {
    case 0: // NE
        north_tile.shift(-2, -10);
        //            north_tile.x -= 2;
        //            north_tile.y -= 10;
        break;
    case 1: // SE
        north_tile.shift(0, -2);
        //            north_tile.y -= 2;
        break;
    case 2: // SW
        north_tile.shift(-2, 0);
        //            north_tile.x -= 2;
        break;
    case 3: // NW
        north_tile.shift(-10, -2);
        //            north_tile.x -= 10;
        //            north_tile.y -= 2;
        break;
    }

    // first, add base tiles
    switch (orientation) {
    case 0: { // NE
        int MASTABA_SCHEME[13][5] = {
          {til_3, lst1A, lst1B, til_1, lst3A},
          {til_2, lst1A, lst1B, til_1, lst3A},
          {til_3, lst1A, lst1B, til_1, lst3A},
          {til_2, til_0, til_0, til_1, til_0},
          {til_0, til_0, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {til_1, til_1, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {til_1, til_1, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
        };
        for (int row = 0; row < 13; row++) {
            for (int column = 0; column < 5; column++)
                map_image_set(MAP_OFFSET(north_tile.x() + column, north_tile.y() + row),
                              MASTABA_SCHEME[row][column]);
        }
        break;
    }
    case 1: { // SE
        int MASTABA_SCHEME[5][13] = {
          {smst0, smst0, til_1, smst0, smst0, til_1, smst0, smst0, til_0, til_2, til_3, til_2, til_3},
          {til_0, til_0, til_1, til_0, til_0, til_1, til_0, til_0, til_0, til_0, lst2B, lst2B, lst2B},
          {EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, til_0, lst2A, lst2A, lst2A},
          {EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, til_1, til_1, til_1, til_1},
          {EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, til_0, lst0B, lst0B, lst0B},
        };
        for (int row = 0; row < 5; row++) {
            for (int column = 0; column < 13; column++)
                map_image_set(MAP_OFFSET(north_tile.x() + column, north_tile.y() + row),
                              MASTABA_SCHEME[row][column]);
        }
        break;
    }
    case 2: { // SW
        int MASTABA_SCHEME[13][5] = {
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {til_1, til_1, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {til_1, til_1, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {smst3, til_0, EMPTY, EMPTY, EMPTY},
          {til_0, til_0, EMPTY, EMPTY, EMPTY},
          {til_2, til_0, til_0, til_1, til_0},
          {til_3, lst1A, lst1B, til_1, lst3A},
          {til_2, lst1A, lst1B, til_1, lst3A},
          {til_3, lst1A, lst1B, til_1, lst3A},
        };
        for (int row = 0; row < 13; row++) {
            for (int column = 0; column < 5; column++)
                map_image_set(MAP_OFFSET(north_tile.x() + column, north_tile.y() + row),
                              MASTABA_SCHEME[row][column]);
        }
        break;
    }
    case 3: { // NW
        int MASTABA_SCHEME[5][13] = {
          {til_3, til_2, til_3, til_2, til_0, smst0, smst0, til_1, smst0, smst0, til_1, smst0, smst0},
          {lst2B, lst2B, lst2B, til_0, til_0, til_0, til_0, til_1, til_0, til_0, til_1, til_0, til_0},
          {lst2A, lst2A, lst2A, til_0, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY},
          {til_1, til_1, til_1, til_1, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY},
          {lst0B, lst0B, lst0B, til_0, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY},
        };
        for (int row = 0; row < 5; row++) {
            for (int column = 0; column < 13; column++)
                map_image_set(MAP_OFFSET(north_tile.x() + column, north_tile.y() + row),
                              MASTABA_SCHEME[row][column]);
        }
        break;
    }
    }
}

Но потом я смотрел на получившийся код и понимал, что пройдет месяц, и всё это забудется, потому что сложно. Поэтому начал копать интернеты в поисках, чего-то более простого для понимания. Решение сложилось после этой статьи, которая довольно неплохо описывает принципы отрисовки комбинированных зданий в изометрии. И это привело в итоге к тому, чтобы разбить мастабу на составные части и отрисовывать их по общим правилам изометрии.

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

Собираем кирпичи

Текстуры для частей мастабы разбиты на сегменты одинакового размера, из которых собирается все здание. Размер мастабы может варьироваться от 2×3 до любого вменяемого для конкретной карты, у той что на картинках выше — 2×5. В ресурсах эти данные разбиты на два файла, mastaba.sg3 — это описание (sg3 — это sierra graphics v3). Формат сжатия текстур, разработанный сотрудниками Sierra Entertainment в 1988 году, и применявшийся в большинстве игр студии, но за пределами не особо известный. Дает хорошие результаты при упаковке текстур с RGB данными в 16бит, все пиксели со значением 0xf81f интерпретируются как прозрачные, это сделано с расчетом на последующий RLE упаковщик, который умеет сворачивать такие последовательности одинаковых пикселей в пару байт. Более подробно про формат можно почитать тут.

Данные текстур хранятся в файлах.555. Большинство изображений находятся в файле.555 с тем же именем, что и файл.sg3, это было сделано с расчетом на возможные патчи, чтобы иметь возможность отдавать пользователям частичные изменения. Интернет тогда был не особо быстрый, поэтому экономия даже 0.5Mb, который занимает sg3 файл было весомым аргументом к такой архитектуре.

Сами графические данные могут быть упакованы разными способами в файлах.555, в зависимости от типа текстуры:

  1. Несжатая — такие изображения хранятся «как есть» по строкам, сверху вниз, слева направо в каждой строке. Таким образом, если изображение имеет размеры 20×30 пикселей, это означает, что данные для этого изображения составляют 20 * 30 = 600 беззнаковых 16-битных целых чисел, представляющих цвета каждого пикселя.

  2. Сжатая — прозрачные пиксели для этих изображений кодируются с использованием кодирования по длине серий. Этот формат остался еще с Caesar II, и использовался все меньше. Данные обрабатываются байт за байтом следующим образом:

    • Считывание 1 uchar Factor

    • Если Factor равно 255:

      • Считывание следующих байтов после Factor * количество пикселей, которые нужно сделать прозрачными

      • Сделать следующие Factor пикселей прозрачными на изображении

    • В противном случае: Factor указывает количество пикселей для чтения

      • Считывание Factor 16-битных значений пикселей изображения

  3. Изометрическая — текстуры с шириной кратной 30 пикселям и состоят из двух частей:

    • «Базовая» часть: ромбовидная основа тайла, хранящаяся в несжатом виде, потому что прозрачных пикселей быть не должно. Размеры базовой текстуры определяются игрой, к которой принадлежит файл SG: Caesar 3, Pharaoh, Zeus используют плитки размером 58×30

    • «Верхняя» часть: оставшиеся пиксели, которые не являются частью базовой, и так как они могут содержать прозрачные места, то хранятся в сжатом виде.

    • Но это еще не всё, если вы посмотрите на изометрический тайл, то заметите, что половина пространства не используется, это прозрачные пиксели и их можно убрать из кодирования, просто пропуская эти участки. Таким образом записываются только значащие пиксели, и размер текстуры становится еще меньше. Например, при таком алгоритме упаковки текстура размером 10×6 из 60 пикселей упаковывается в 36.

Далее по этим данным, которые вычитаны, распакованы и правильно собраны получаются полноценные текстуры. Формат хранения был рассчитан в первую очередь на семейство OC windows и его графический стек, позволявший быстро блитить (накладывать) текстуры без прозрачных пикселей.

Готовим стройплощадку

Сам процесс строительства разбит на 8 частей, два выравнивания стройплощадки, и 6 этапов укладки камней. Для постройки мастабы нужен камень, для укладки одного сегмента нужно 400 камней, которые должны быть доставлены со склада на стройку. После чего bricklayers начинают укладку (скриншот из open source версии).

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

int building_small_mastabe_get_image(int orientation, tile2i tile, tile2i start, tile2i end) {
    int image_id = image_group(IMG_SMALL_MASTABA);
    int base_image_id = image_id - 7;
    bool insidex = (tile.x() > start.x() && tile.x() < end.x());
    bool insidey = (tile.y() > start.y() && tile.y() < end.y());
    int random = (image_id + 5 + (tile.x() + tile.y()) % 7);
    int result = random;
    if (tile == start) { // top corner
        result = image_id;
    } else if (tile == tile2i(start.x(), end.y())) {
        result = image_id - 2;
    } else if (tile == end) {
        result = image_id - 4;
    } else if (tile == tile2i(end.x(), start.y())) {
        result = image_id - 6;
    } else if (tile.x() == start.x()) {
        result = image_id - 1;
    } else if (tile.y() == end.y()) {
        result = image_id - 3;
    } else if (tile.y() == start.y()) {
        result = (insidex || insidey) ? image_id - 7 : random;
    } else if (tile.x() == end.x()) {
        result = image_id - 5;
    }

    if (result < random) {
        int offset = result - base_image_id;
        result = (base_image_id + (offset + (8 - city_view_orientation())) % 8);
        return result;
    }

    return result;
}

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

Чиним баги рендера

Из‑за нового подхода к отрисовке мастабы получилась неправильная отрисовка отдельных частей. Пришлось немного дописать рендер, чтобы научить движок отрисовывать верхние части текстур. Для этого оригинальная текстура разбивается на две части, подложку и верхнюю часть. Тогда при наложении их друг на друга, они будут давать цельную текстуру, которая избавлена от ошибок порядка отрисовки в изометрии. Все подложки можно отрисовать на первом проходе, это избавит от перекрытия с фигурами жителей, затем наложить верхнюю часть, чтобы скрыть жителей который оказались за зданием. В итоге получаем нормальный вид зданий. (скриншот из open source версии)

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

Переиспользование кода и логики

Также в Фараоне, специально под монументы, появилась динамическая модель (насколько я понял из доступных исходников) ресурсов — монумент это фактически был склад, с выставленными правилами товаров. Если в Цезаре место под ресурсы бронировалось на складе сразу, то здесь кирпичи появлялись на стройплощадке, только когда они физически были доставлены на волокуше. Это с одной стороны позволило абьюзить игру сейв‑лоадами, например если поймать момент когда носильщик выгружает товар, то загрузив сейв можно было получить этот товар повторно, а с другой добавило новые связи в симуляции и сделали её сложнее.

Физически ресурсы появлялись на стройплощадке, когда носильщики достигали этой точки.

Багофичи оригинальной игры

В процессе расковыривания всего алгоритма набрел на одну занятную ошибку‑фичу. В игре текстуры разложены в массиве по индексам, и при загрузке из пака (pak — набор текстур), они просто мапятся в память с минимальной обработкой. Очень классное решение, пришедшее ещё из цезаря, но фича была в том, что некоторые индексы могли перекрываться, например pak1 грузит индексы 1–500, а pak2 490–1000. Не знаю причину, по которой это было сделано, но скорее всего просто в пак влезал фиксированный объем изображений. В игре почти не было динамического выделения памяти, только на старте, все что надо было мапилось сразу на нужные адреса. И получалось, что некоторые текстуры переписывали уже проиндексированные. На скриншоте ниже вместо текстур городов рисуются иконки из окна советников. Пришлось делать отдельный ремапинг текстур для карты.

Где посмотреть и попробовать

Проект живет тут. Оригинальный рендер восстановлен и частично переписан, игровые механики, здания и жителей восстанавливаю по мере того, как добираюсь до них. Миссии восстановлены до 5 включительно, для игры вам потребуются ресурсы из Steam/GOG/Roger версии. Или заходите на discord канал, выкладываю там обновления по прогрессу разработки.

Бонус

В американской расширенной версии вместе с диском шла отдельная брошюра, которая описывала производственные цепочки в игре. Умели же делать :( сейчас обычно OST предлагают докупить, и пачку ненужных DLC.

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


  1. azTotMD
    31.03.2024 22:08
    +2

    поэтапное строительство зданий. Да которые еще и требовали наличие разных ресурсов

    Total Annihilation


  1. MasterMentor
    31.03.2024 22:08

    >>Код отрисовки всей мастабы за один проход из оригинала (восстановлено частично)

    Код идеален!


    1. voldemar_d
      31.03.2024 22:08
      +1

      Я бы не делал вычисление city_view_orientation() много раз, а результат вызова сохранил в переменную и далее использовал. Еще массивы MASTABA_SCHEME можно вынести в какое-то глобальное место - хотя, наверное, компилятор здесь сам сделает всё, что надо. А так, ИМХО, код и вправду нормальный, куда его еще упрощать? Только я не гейм-девелопер, возможно, чего-то и не понимаю.


  1. aspirinne
    31.03.2024 22:08

    Умели же делать...

    Вспомнил, как в settlers heritage of kings забывал о самой игре и подолгу смотрел вблизи, как фабрика делает пушки или еще что нибудь. Смена сезонов могла затевтонить всю твою орду на речке. А про торговлю и дипломатию и говорить нечего.

    ...это вам не шесть рапир на снайпера собирать!