Pull to refresh
0
Soletude
Manage thousands of files with a tree of tags

О, «Герои»? Дайте две! Как я писал очередной браузерный клон легендарной стратегии, в который уже почти* можно играть

Level of difficultyEasy
Reading time14 min
Views35K


TL;DR для тех, кому некогда читать™:






Итак, началось всё с хорошо продуманного плана… Подождите, это из другой вселенной. В нашей вселенной всё началось с 2020 года, когда смартфоны ещё продавались официально, но на работу ходить уже было нельзя. В какой-то момент я понял, что нужно переключить свою голову с логической оценки происходящего и проблемы четырёх стен на любую — какую угодно! — задачу трёх тел (главное, чтобы не буквально). Или, перефразируя отца ядерной физики, идея должна была быть достаточно безумной, чтобы за неё взяться. Идея нашлась — и винить в этом следует популярнейшую статью 2018 года «Герои Меча и Магии» в браузере: долго, сложно и невыносимо интересно — написать «клон» третьих «Героев». За месяц.


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


(1) День первый


А жив ли мальчик? Расчехляем OSINT


NB: В этой статье я использую две параллельные группы заголовков: одна временна́я, другая смысловая. Чтобы облегчить восприятие, номера дней я указываю так, как будто бы работа шла без перерыва каждый день. Однако дни не нормализованы — в один день я мог провести за проектом 1 час, а в иной — 11 (мой личный рекорд).


Начал я со сбора информации о существующих проектах. Сходу нашёл две очень старые и поныне живущие русские пошаговые MMO (heroeswm с заявленным онлайном в 5 000 игроков и heroesland с онлайном поменьше), русскую же экономическую инкарнацию в виде mlgame, а также heroes-online.com от Ubisoft, аккурат в 2020 благополучно почившую в бозе. Из открытых движков, помимо всем известного VCMI, был относительно новый и очень активный fheroes2 (в отличие от первого, воссоздаёт не третьих «Героев», а вторых). Этим список живых проектов исчерпывался, если не считать многочисленных модов на основе оригинальных «Героев», кучи мобильных игр, мимикрирующих под оригинал, и появившегося в 2020 движка от Владимира Смирнова (mapron).


Зато сайтов по тематике игры — великое множество:



Даже на прогрессивном Хабре астрологи регулярно объявляют месяц статей о «Героях» — в прошлый раз это было в феврале (что, впрочем, не удивительно).


Поясню для тех, кто не в курсе нынешней кухни «Героев». Самым старым модом считается WoG (In the Wake of Gods) — в прошлом году была даже статья автора на 20-летие проекта. В середине нулевых WoG перестал развиваться, но на его основе появился скриптовый движок ERA, встраивающийся внутрь процесса «Героев» и творящий всевозможные безобразия нестандартные вещи (с точки зрения оригинала).


На базе ERA делается много мелких модификаций, но более масштабные обычно используют свою платформу. В первую очередь это HotA (Horn of the Abyss), воспринимаемая многими как «современные Герои 3», в особенности по части PvP. (ИЧСХ, и WoG, и HotA имеют русские корни.)


Совершенно невероятно, но, и об этом говорилось уже много раз: спустя 24 года, в экосистеме «Героев» каждый год появляется что-то принципиально новое.


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


— Очень приятно, Ниша


Ситуация вокруг «Героев» сложилась любопытная. В одном рейтинге игр для ПК «Герои 3» стоят на 4-м месте, обгоняя Warcraft 3 и Mass Effect — но даже исповедуя здоровый скептицизм, нельзя не признать, что игра до сих пор популярна.



Все четыре официальных продолжения провалились. Новых мало кто ждёт. Реинкарнации от сторонних разработчиков появляются регулярно, но славы оригинала никто не стяжал даже близко. Весь фандом сосредоточен исключительно на модах к оригинальной игре, что накладывает свои ограничения (только Windows, неадаптивный UI, жёсткие рамки оригинального геймплея, крайняя трудоёмкость разработки).


Получается, миру нужны современные оригинальные «Герои» с широкими возможностями для моддинга, а также с чем-то, чего нет ни у VCMI с его 15-летней историей и 3 057 звёздами на GitHub, ни у очень вкусного fheroes2 с 1 971 звездой. Почему народ не спешит портировать туда свои модификации? Что я могу предложить нового? И есть ли шанс в одиночку довести дело хотя бы до альфа-версии?


Ответ напрашивался сам собой…



Если вы хотите расширяемости, то это не про C++. Это даже не очень про Java (см. Xposed). Но это очень даже про JavaScript, особенно если использовать Sqimitive. Вместо API — вся кодовая база; вызывай что хочешь, перекрывай как знаешь. Кто-то спросит — разве можно так писать? Да, если трактористы — женщины! ведь не забудьте: у нас уже есть альтернативы на C++, написанные по всем правилам. Значит, будем писать не по правилам!


Минутка самопиара. Я очень люблю универсальные технологии. Фреймворк Sqimitive — на котором построено всё здание HeroWO и несколько моих проектов поменьше — основан на идее, что любой метод любого объекта есть событие, а на события можно подписываться (с заданным приоритетом), перекрывать имеющиеся обработчики, откладывать их вызов и даже пакетировать (batch). Это позволяет решать множество прикладных задач через единый механизм: например, наследование класса есть просто изменение списка слушателей в подклассе, а множественное наследование — серия таких изменений, и делать их можно после объявления класса (aka mix-ins). Размер всего фреймворка — порядка 1 500 строк, зато документация, описывающая возможности применения — порядка 200 страниц.


Интерпретируемый язык сам по себе сократит время разработки и объём кода, но ведь мы говорим о JavaScript в браузере — а современные браузеры специально предназначены для презентации (очень) сложного контента. HTML и CSS для не очень динамичной игры вроде «Героев» — это что-то уровня червоточины в пространстве: с их помощью я смогу перепрыгнуть через целые пласты игровых подсистем. А проект, может быть, даже сможет выжить.


Продолжая мысль, если расширяемость ставить во главу угла, то ядро движка должно быть гибким, иначе мы получим тех же «Героев» или VCMI, только в профиль. И чем более гибким, тем больше шанс, что в будущем «Герои» в форме HeroWO выйдут-таки из стазиса и станут современной игрой с хардкорным олдскульным нутром, а может даже вберут в себя существующие модификации, а то и целиком старые RTS вроде Disciples!



Картинка получалось захватывающая. Решив, что корованы того стоят, я взялся за дело.


(2) День второй


Игровые архивы и форматы данных


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


Бо́льшую часть второго дня я провёл за безуспешными попытками скомпилировать h3m-map-convertor и его зависимость homm3tools. «Герои» хранят игровые карты в файлах с расширением .h3m («Heroes 3 Map»), и, насколько мне известно, общедоступных парсеров этого формата существует ровно один — h3mlib от homm3tools. Качество кода h3mlib удручает: ручной разбор данных на Си — занятие неблагодарное, но когда это Си с макросами, то призыв четырёх пони апокалипсиса становится вопросом времени. Впрочем, главная проблема была в том, что даже после танцев с бубном и успешной компиляции библиотека отказывалась читать официальные карты, причём ошибка возникала где-то внутрях Zlib. В процессе всего этого акта меня не покидало ощущение, что h3m-map-convertor, автором которого, собственно, и является lekzd, базировался на какой-то подпольной более новой версии homm3tools, которая не была доступна простым смертным вроде меня.


В конце концов, я оставил попытки собрать собственный конвертер .h3m в JSON, решив, что 11-ти уже сконвертированных карт, которые я смог вытащить с демо-сайта lekzd, мне вполне хватит для начала работы. Забегая вперёд, скажу: мне хватило лишь одной, той самой — «Adventures of Jared Haret» (можете запустить её в HeroWO), а на 105-й день я написал свой парсер, с рулеткой и мета-данными, сотнями проверок, компилятором и прочими шплюшками.


(4) День четвёртый


Поскольку я не собирался делать клон движка lekzd, то рассматривал его JSON-ы как промежуточный формат. В этот день я написал конвертер «lekzd-json» в формат HeroWO, где сделал самое простое преобразование: пересчёт координат объектов с указания правой нижней точки (как в оригинальных «Героях» и во многих других старых играх) на указание левой верхней точки.



В этот же день я изучил файлы, задающие параметры различных игровых механик. Вкратце, «Герои» хранят данные в архивах с расширением .lod (а также идентичных .snd и .vid). Таких архивов имеется четыре (открываются через MMArchive):



  • H3bitmap.lod — статические изображения (например, портреты героев) и текстовые файлы с константами. Текстовики можно открыть либо в Excel, либо в TxtEdit. Рисунки — 8-битные PCX (это такой BMP эпохи DOS). Да, в «Героях» используется всего 256 цветов! Сразу и не скажешь, правда?


  • H3sprite.lod — анимированные изображения с расширением .def. По сути, 8-битная графика с разными способами сжатия. DefPreview умеет их показывать и экспортировать в BMP, а DefTool умеет их собирать.


  • Heroes3.snd — игровые звуки (музыка находится в стандартных MP3-шках в самой папке игры); стандартный WAV, но в модуляции DVI-ADPCM — браузеры и многий софт (включая oggenc) её не понимают. Получить «обычный» WAV можно с помощью моей утилитки adpcm2pcm или Audacity или SoX.
  • VIDEO.VID — видеоролики в ныне канувшем в лету (купленном Epic), а на рубеже веков чрезвычайно популярном формате Bink Video. Официальные RAD Game Tools до сих пор запускаются на Windows 10 и позволяют экспортировать кадры и звук.

В репозитории HeroWO есть папка databank с текстовыми файлами — в них я детально описываю данные «Героев», в том числе содержимое архивов и типы используемых файлов и назначение графических и звуковых файлов. В Сети до сих пор нет единого места с такой информацией, поэтому если вы можете что-то дополнить или исправить — пожалуйста, присылайте PR! Обещаю, никто не уйдёт обиженным.


Пользуясь случаем, хочу сказать спасибо нашему соотечественнику Сергею Роженко aka grayface, создавшему в нулевые годы десятки утилит для работы с данными «Героев», исходники которых он выложил на GitHub. Сергей, да пребудет с тобой навечно Сила Delphi!


Разобравшись с форматами в начальном приближении, под конец дня я написал самый первый код для клиентской части: 230 строк (под спойлером), отрисовывающих карту «Приключений Жареда Харета» в базовой версии (как на КДПВ). В последующие годы я до того насмотрелся на эту карту, что мог бы по памяти нарисовать стартовый экран, где героя в проходе зажали Троглодиты… но мы отвлеклись.


Скрытый текст
<!DOCTYPE html>
<html>
  <head>
    <title>HeroWO</title>
  </head>
  <body>
    <div id="herowo"></div>

    <script src="nodash.min.js"></script>
    <script src="sqimitive.min.js"></script>

    <script>
      var ObjectStore = Sqimitive.Base.extend({
        _schema: {},
        _schemaLength: 0,
        _layers: [],
        _layerLength: 0,
        _maxLayer: 0,
        _strideX: 0,
        _strideY: 0,
        _strideXY: 0,
        _maxZ: 0,

        events: {
          // opt:
          //> schema    {prop1: 0, prop2: 1, prop3: 1}
          //> layers    [ [o1p1, o1p2, o2p1, o2p2, ...], [o3p1, o3p2, null, null] ]
          //> strideX   set to 1 if not using that coord (1D array)
          //> strideY   set to 1 if not using that coord (2D array)
          init: function (opt) {
            // Passed arrays are not cloned for performance. Clone them before
            // passing if planning to change.
            this._schema = opt.schema
            this._schemaLength = _.max(_.filter(this._schema, function (v, k) {
              return k.indexOf('.') == -1
            })) + 1
            this._layers = opt.layers
            this._layerLength = this._layers[0].length
            this._maxLayer = this._layers.length - 1
            this._strideX = opt.strideX
            this._strideY = opt.strideY
            this._strideXY = opt.strideX * opt.strideY
            this._maxZ = this._layerLength / this._strideXY / this._schemaLength - 1
            if (Math.floor(this._maxZ) !== this._maxZ) {
              throw new Error('Stride parameters do not match the number of members.')
            }
          },
        },

        // prop - either resolved to integer or name of the outermost prop
        // (not 'foo.bar'). It's used in other methods; numeric argument works
        // faster so pre-resolve property index when doing heavy calculations.
        // Doesn't check if prop exists in the schema.
        // There's no "propertyByIndex()" because multiple properties may live
        // on one index ("union").
        propertyIndex: function (prop) {
          return typeof prop == 'number' ? prop : this._schema[prop]
        },

        // Unlike advance(), doesn't check if x/y/z are within the boundaries.
        toContiguous: function (x, y, z, prop) {
          return (z * this._strideXY + y * this._strideX + x)
                  * this._schemaLength + this.propertyIndex(prop)
        },

        fromContiguous: function (n) {
          var prop = n % this._schemaLength
          n = (n - prop) / this._schemaLength
          var x = n % this._strideY
          n = (n - x) / this._strideY
          var y = n % this._strideX
          n = (n - y) / this._strideX
          return {z: n, y: y, x: x, prop: prop}
        },

        // Wraps around. Stop when returns negative.
        //
        // for (var n = toContiguous(1, 2, 3, 'foo'); n >= 0; n = advance(n, -2))
        //   for (var fooValue, l = 0; null != (fooValue = atContiguous(n, l)); l++)
        //     alert(fooValue)
        advance: function (n, by) {
          n += by * this._schemaLength
          return n >= this._layerLength ? -1 : n
        },

        // If need to retrieve multiple properties of the same object, give
        // prop = 0 and use the passed n:
        // var prop = propertyIndex('foo')
        // findWithin(..., 0, function (..., l, n) { atContiguous(n + prop, l) })
        findWithin: function (sx, sy, sz, ex, ey, ez, prop, func, cx) {
          cx = cx || this
          for (var n = this.toContiguous(sx, sy, sz, prop);
               n >= 0 && (sx != ex || sy != ey || sz != ez);
               n = this.advance(n, +1)) {
            for (var value, l = 0; null != (value = this.atContiguous(n, l)); l++) {
              value = func.call(cx, value, sx, sy, sz, l, n)
              if (value) { return value }
            }
            if (this._strideX <= ++sx) {
              sx = 0
              if (this._strideY <= ++sy) {
                sy = 0
                sz++
              }
            }
          }
        },

        find: function (prop, func, cx) {
          return this.findWithin(0, 0, 0, Infinity, Infinity, Infinity, prop, func, cx)
        },

        // Convention: x/y/z - coords, l - layer (depth), prop - property index
        // or name, n - contiguous index of x/y/z/prop (but not l).
        atCoords: function (x, y, z, prop, l) {
          return this.atContiguous(this.toContiguous(x, y, z, prop), l)
        },

        // Returns == null when there are no more objects at l and below.
        // n must be within boundaries.
        atContiguous: function (n, l) {
          return l > this._maxLayer ? null : this._layers[l][n]
        },
      })

      var HMap = Sqimitive.Base.extend({
        objects: null,    // ObjectStore; do not set

        _opt: {
          state: 'created',   // created, loading, loaded
          url: '',
          format: 0,
          origin: '',
          width: 0,
          height: 0,
          levels: 0,
          difficulty: 0,
          title: 0,
          description: 0,
        },

        fetch: function (url) {
          if (this.get('state') != 'created') {
            throw new Error('Must fetch() only on a new Map.')
          }

          this.set('url', url)
          this.set('state', 'loading')
          var async = new Sqimitive.Async.Fetch({dataType: 'json', url: url + 'map.json'})

          return async
            .whenSuccess(function () {
              this.assignResp(async.response)
              this._fetchObjects()
            }, this)
        },

        _fetchObjects: function () {
          var async = new Sqimitive.Async
          async.nest('o', new Sqimitive.Async.Fetch({dataType: 'json', url: this.get('url') + 'objects.json'}))
          //async.nest('c', new Sqimitive.Async.Fetch({dataType: 'json', url: this.get('url') + 'classes.json'}))

          return async
            .whenSuccess(function () {
              var objects = async.nested('o').response
              this.objects = new ObjectStore(objects)
              this.set('state', 'loaded')
            }, this)
        },
      })

      var el = $('#herowo')
      $('body').css('background', 'cyan')
      var map = new HMap

      map.on('change_state', function (now) {
        if (now == 'loaded') {
          var oclass = this.objects.propertyIndex('class')
          var osubclass = this.objects.propertyIndex('subclass')
          var otexture = this.objects.propertyIndex('texture')
          var owidth = this.objects.propertyIndex('width')
          var oheight = this.objects.propertyIndex('height')
          var ox = this.objects.propertyIndex('x')
          var oy = this.objects.propertyIndex('y')
          var oz = this.objects.propertyIndex('z')
          var oabove = this.objects.propertyIndex('isAbove')
          var omirrorX = this.objects.propertyIndex('mirrorX')
          var omirrorY = this.objects.propertyIndex('mirrorY')
          window.o = this.objects
          this.objects.find(0, function (val, x, y, z, l, n) {
            z = this.atContiguous(n + oz, l)
            if (z == 1) {
              var tn = 0
              var c = this.atContiguous(n + oclass, l)
              var s = this.atContiguous(n + osubclass, l)
              if (c >= 256) {
                tn = s
              }
              x = this.atContiguous(n + ox, l)
              y = this.atContiguous(n + oy, l)
              $('<div>')
                .css({
                  position: 'absolute',
                  left: x * 32,
                  top: y * 32,
                  width: this.atContiguous(n + owidth, l) * 32,
                  height: this.atContiguous(n + oheight, l) * 32,
                  zIndex: this.atContiguous(n + oabove, l) * 10000 + ( (y + this.atContiguous(n + oheight, l)) * 10 + l + 1 ),
                  background: 'rgba(255,0,0,.0) url(../../def-png/' + this.atContiguous(n + otexture, l) + '/0-' + tn + '.png)',
                  //outline: '1px dashed rgba(255, 0, 0, .2)',
                  transform:
                    'scale(' +
                    (this.atContiguous(n + omirrorX, l) ? -1 : +1) +
                    ', ' +
                    (this.atContiguous(n + omirrorY, l) ? -1 : +1) +
                    ')',
                })
                .attr({
                  title: x + ':' + y + ' ' + this.atContiguous(n + oabove, l) + ' ' + this.atContiguous(n + otexture, l)
                })
                .appendTo(el)
            }
          })
        }
      })

      map.fetch('maps/converted/')
    </script>
  </body>
</html>



Части этого кода пережили все рефакторинги и их можно найти даже в текущей версии: ObjectStore, Map, DOM.Map.


Отрисовка вскрыла и первые проблемы, вторая из которых не решена до сих пор:


  • Объекты на карте могут выходить за её границы. Это очень часто случается с элементами ландшафта — горы и лес не помещаются целиком в рамки карты и должны частично обрезаться. Реализация обрезания усложнила бы циклы: стало бы недостаточно перебирать все точки объекта — нужны проверки, не находится ли точка за пределами карты, чтобы не выйти за границы массива. Альтернатива в виде создания обрезанных вариаций объектов при конвертации карты тоже имела свои недостатки. Я решил эту проблему в стиле Warcraft 3: добавил невидимую область по краям, которую движок воспринимает как полноценную часть карты, но которая перекрывается пользовательским интерфейсом.


  • Z-координата (глубина/дальность от глаз пользователя) вычисляется неясным образом. Вначале я думал, что она зависит от порядка следования объектов внутри .h3m и их координат, но после многочасовых экспериментов с редактором карт я оставил попытки разобраться, как же именно она определяется, так что текущая формула выглядит не очень. Кто знает, расскажите!


ObjectStore: универсальное хранилище данных HeroWO


В этот день было принято первое судьбоносное решение, которое легло в основу движка и, как говорят за океаном, драматично упростившее ряд вещей в дальнейшем. Решение это мне подсказала всё та же статья (раздел «Хранение данных»). Я приведу оттуда слайд, отлично иллюстрирующий концепцию:



Я уже говорил, что люблю универсальные технологии? Так вот, в HeroWO вообще все данные хранятся в виде пары десятков огромных массивов внутри объектов класса докObjectStore. Например, objects.json — хранилище объектов на карте приключений:



У каждого хранилища есть своя схема (таблица с именами полей для каждого индекса), размеры и массив слоёв, в каждом из которых «россыпью» хранятся данные отдельных объектов. На скриншоте выше показано начало объекта-героя «Жареда Харета»: первая выделенная строка — значение для свойства actionable (список клеток в границах объекта, с которыми можно взаимодействовать), вторая — actionableFromTop (флаг, допускающий взаимодействие с объектом с меньшим Y) и так далее.


В «Приключениях» всего 3 161 объект (включая тайлы земли); размер схемы (число элементов в массиве на один объект) — 45; итого, длина layers равняется 142 245 элементам. Второе и последнее большое хранилище — effects.json (о нём позже), там 3 046 объектов и 231 496 элементов. Если в Chrome сравнить потребление памяти с массивом из объектов (вида {actionable: "000010", actionableFromTop: true, ...}), то увидим выгоду в 32%: 1 205 004 байта против 822 840 у докObjectStore.


Правда, в текущей версии хранение плохо оптимизировано (например, для самых многочисленных объектов — тайлов — схема в два раза короче, и остальное забивается null-ами), но доработать это сравнительно легко.


Инкапсуляция всех данных в структуре одного типа позволяет делать разные полезные штуки. Например, через месяц после начала работы над проектом я добавил поддержку вложенных хранилищ (массивы слоёв с собственной схемой) — на скриншоте это поле garrison сразу под выделением, где 7 есть creature, а 10 — count. Дальше, в конце схемы (стрелка влево) видно, что два поля имеют одинаковый индекс — это объединение (union) для опциональных полей, которые не могут использоваться одновременно и потому хранятся в общей ячейке (в нашем случае, message существует только у квестовых объектов, а available — только у городов, которые ими не являются). Объединения экономят место и создаются автоматически путём анализа пересечений использования свойств в схеме; например (слева — тип значения, справа — разъединяющая характеристика объектов):


$message:
    array  *str    - quest
            str    - quest
    array  *str    - treasure
            str    - treasure
    array  *str    - event
            str    - event
    array  *str    - monster
            str    - monster
$available:
            non-layered 1D sub-store - town
            non-layered 1D sub-store - dwelling
            non-layered 1D sub-store - hero

В своём движке lekzd использовал такую систему вынужденно ради скорости; я же с самого начала положил её в основу проекта, полагая, что она радикально облегчит сериализацию игрового мира. Для примера, конвертер карт оригинальных «Героев» (h3m2json.php) занимает 4 475 строчек, плюс 602 строки с комментариями. В то же время, сохранение и загрузка карты в формате HeroWO — это просто череда вызовов JSON.stringify()/JSON.parse() без какой-либо подготовки данных. Подкупало и то, что наличие единой точки доступа к данным (в лице докObjectStore) должно было сильно упростить синхронизацию клиентов в многопользовательской игре — и действительно, сейчас там порядка 650 строк (сервер, клиент), что в разы меньше, чем один только парсер карт в homm3tools.


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


Прошло всего 4 дня, а карта уже, считай, готова. График, вроде, выдерживаем, ещё недельку — и ка-ак зарелизим! Казалось бы, что могло пойти не так…



herowo.gameФорумDiscordYouTubeGitHub


Вторая часть статьи →

Tags:
Hubs:
Total votes 191: ↑191 and ↓0+191
Comments79

Articles

Information

Website
soletude.ca
Registered
Employees
2–10 employees