- PVSM.RU - https://www.pvsm.ru -

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

Дисклеймер: Я часто привожу написанное число строчек, затраченное число часов и даже личные мысли. Делаю это не ради хвастовства, а для более глубокого понимания происходящего. КМК, данная статья — очень редкий случай, когда, благодаря детальным записям с первого дня, можно проследить эволюцию крупного проекта от самого зарождения. Смотря лишь на конечный результат, никогда не поймёшь, что человек чувствовал и пережил ради достижения поставленной цели, а без этого можно сделать превратные выводы как о самом человеке, так и о собственных амбициозных прожектах.
NB: В этой статье я использую две параллельные группы заголовков: одна временна́я, другая смысловая. Чтобы облегчить восприятие, номера дней я указываю так, как будто бы работа шла без перерыва каждый день. Однако дни не нормализованы — в один день я мог провести за проектом 1 час, а в иной — 11 (мой личный рекорд).
Начал я со сбора информации о существующих проектах. Сходу нашёл две очень старые и поныне живущие русские пошаговые MMO (heroeswm [22] с заявленным онлайном в 5 000 игроков и heroesland [23] с онлайном поменьше), русскую же экономическую инкарнацию в виде mlgame [24], а также heroes-online.com от Ubisoft, аккурат в 2020 благополучно почившую в бозе [25]. Из открытых движков, помимо всем известного VCMI [26], был относительно новый и очень активный fheroes2 [27] (в отличие от первого, воссоздаёт не третьих «Героев», а вторых). Этим список живых проектов исчерпывался, если не считать многочисленных модов на основе оригинальных «Героев», кучи [28] мобильных [29] игр [30], мимикрирующих под оригинал, и появившегося в 2020 FreeHeroes [31] от Владимира Смирнова (mapron [32]).
Зато сайтов по тематике игры — великое множество:
Даже на прогрессивном Хабре астрологи регулярно объявляют месяц статей о «Героях» — в прошлый раз это было [60] в феврале [61] (что, впрочем, не удивительно [62]).
Поясню для тех, кто не в курсе нынешней кухни «Героев». Самым старым модом считается WoG (In the Wake of Gods) — в прошлом году была даже статья автора [63] на 20-летие проекта. В середине нулевых WoG перестал развиваться, но на его основе появился скриптовый движок ERA, встраивающийся внутрь процесса «Героев» и творящий всевозможные безобразия нестандартные вещи (с точки зрения оригинала).
На базе ERA делается много мелких модификаций, но более масштабные обычно используют свою платформу. В первую очередь это HotA (Horn of the Abyss), воспринимаемая многими как «современные Герои 3», в особенности по части PvP. (ИЧЗХ, и WoG, и HotA имеют русские корни.)
Совершенно невероятно, но, и об этом говорилось уже много [64] раз [65]: спустя 24 года, в экосистеме «Героев» каждый год [66] появляется [67] что-то [68] принципиально [69] новое [70].
Увидев такое буйство флоры и фауны, я понял, что проект может рассчитывать на свою нишу.
Ситуация вокруг «Героев» сложилась любопытная. В одном рейтинге игр для ПК [71] «Герои 3» стоят на 4-м месте, обгоняя Warcraft 3 и Mass Effect — но даже исповедуя здоровый скептицизм, нельзя не признать, что игра до сих пор популярна.

Все четыре официальных продолжения провалились [72]. Новых мало кто ждёт. Реинкарнации от сторонних разработчиков появляются регулярно, но славы оригинала никто не стяжал даже близко. Весь фандом сосредоточен исключительно на модах к оригинальной игре, что накладывает свои ограничения (только Windows, неадаптивный UI, жёсткие рамки оригинального геймплея, крайняя трудоёмкость разработки).
Получается, миру нужны современные оригинальные «Герои» с широкими возможностями для моддинга, а также с чем-то, чего нет ни у VCMI с его 15-летней историей и 3 057 звёздами на GitHub [73], ни у очень вкусного [74] fheroes2 с 1 971 звездой [27]. Почему народ не спешит портировать туда свои модификации? Что я могу предложить нового? И есть ли шанс в одиночку довести дело хотя бы до альфа-версии?
Ответ напрашивался сам собой…

Если вы хотите расширяемости, то это не про C++. Это даже не очень про Java (см. Xposed [75]). Но это очень даже про JavaScript, особенно если использовать Sqimitive [76]. Вместо API — вся кодовая база; вызывай что хочешь, перекрывай как знаешь. Кто-то спросит — разве можно так писать? Да, если трактористы — женщины! [77] ведь не забудьте: у нас уже есть альтернативы на C++, написанные по всем правилам. Значит, будем писать не по правилам!
Минутка самопиара. Я очень люблю универсальные технологии. Фреймворк Sqimitive [78] — на котором построено всё здание HeroWO и несколько моих проектов поменьше — основан на идее, что любой метод любого объекта есть событие, а на события можно подписываться (с заданным приоритетом), перекрывать имеющиеся обработчики, откладывать их вызов и даже пакетировать (batch). Это позволяет решать множество прикладных задач через единый механизм: например, наследование класса есть просто изменение списка слушателей в подклассе, а множественное наследование — серия таких изменений, и делать их можно после объявления класса (aka mix-ins). Размер всего фреймворка — порядка 1 500 строк, зато документация, описывающая возможности применения — порядка 200 страниц.
Интерпретируемый язык сам по себе сократит время разработки и объём кода, но ведь мы говорим о JavaScript в браузере — а современные браузеры специально предназначены для презентации (очень) сложного контента. HTML и CSS для не очень динамичной игры вроде «Героев» — это что-то уровня червоточины в пространстве: с их помощью я смогу перепрыгнуть через целые пласты игровых подсистем. А проект, может быть, даже сможет выжить.
Продолжая мысль, если расширяемость ставить во главу угла, то ядро движка должно быть гибким, иначе мы получим тех же «Героев» или VCMI, только в профиль. И чем более гибким, тем больше шанс, что в будущем «Герои» в форме HeroWO выйдут-таки из стазиса и станут современной игрой с хардкорным олдскульным нутром, а может даже вберут в себя существующие модификации, а то и целиком старые RTS вроде Disciples!

Картинка получалось захватывающая. Решив, что корованы того стоят, я взялся за дело.
Как-то так получилось, что имея пример великолепной статьи lekzd [79] перед глазами, я изначально документировал весь процесс разработки, так что восстановить последовательность событий не составляет труда.
Бо́льшую часть второго дня я провёл за безуспешными попытками скомпилировать h3m-map-convertor [80] и его зависимость homm3tools [81]. «Герои» хранят игровые карты в файлах с расширением .h3m («Heroes 3 Map»), и, насколько мне известно, общедоступных парсеров этого формата существует ровно один — h3mlib от homm3tools (в 2020 ещё появился FreeHeroes [31] со своим парсером). Качество кода h3mlib удручает: ручной разбор данных на Си — занятие неблагодарное, но когда это Си с макросами, то призыв четырёх пони апокалипсиса становится вопросом времени. Впрочем, главная проблема была в том, что даже после танцев с бубном и успешной компиляции библиотека отказывалась читать официальные карты, причём ошибка возникала где-то внутрях Zlib. В процессе всего этого акта меня не покидало ощущение, что h3m-map-convertor, автором которого, собственно, и является lekzd [79], базировался на какой-то подпольной более новой версии homm3tools, которая не была доступна простым смертным вроде меня.
В конце концов, я оставил попытки собрать собственный конвертер .h3m в JSON, решив, что 11-ти уже сконвертированных карт, которые я смог вытащить с демо-сайта [82] lekzd [79], мне вполне хватит для начала работы. Забегая вперёд, скажу: мне хватило лишь одной, той самой — «Adventures of Jared Haret» (можете запустить её в HeroWO [83]), а на 105-й день я написал свой парсер [1], с рулеткой и мета-данными, сотнями проверок, компилятором и прочими шплюшками.
Поскольку я не собирался делать клон движка lekzd [79], то рассматривал его JSON-ы как промежуточный формат. В этот день я написал конвертер «lekzd-json» в формат HeroWO, где сделал самое простое преобразование: пересчёт координат объектов с указания правой нижней точки (как в оригинальных «Героях» и во многих других старых играх) на указание левой верхней точки.

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

H3bitmap.lod — статические изображения (например, портреты героев) и текстовые файлы с константами. Текстовики можно открыть либо в Excel, либо в TxtEdit [85]. Рисунки — 8-битные PCX (это такой BMP эпохи DOS). Да, в «Героях» используется всего 256 цветов! Сразу и не скажешь, правда?
H3sprite.lod — анимированные изображения с расширением .def. По сути, 8-битная графика с разными способами сжатия. DefPreview [86] умеет их показывать и экспортировать в BMP, а DefTool [87] умеет их собирать.
Heroes3.snd — игровые звуки (музыка находится в стандартных MP3-шках в самой папке игры); стандартный WAV, но в модуляции DVI-ADPCM — браузеры и многий софт (включая oggenc) её не понимают. Получить «обычный» WAV можно с помощью моей утилитки adpcm2pcm [88] или Audacity [89] или SoX [90].VIDEO.VID — видеоролики в ныне канувшем в лету (купленном Epic [91]), а на рубеже веков чрезвычайно популярном формате Bink Video. Официальные RAD Game Tools [92] до сих пор запускаются на Windows 10 и позволяют экспортировать кадры и звук.В репозитории HeroWO есть папка databank [93] с текстовыми файлами — в них я детально описываю данные «Героев», в том числе содержимое архивов и типы используемых файлов [94] и назначение графических [95] и звуковых [96] файлов. В Сети до сих пор нет единого места с такой информацией, поэтому если вы можете что-то дополнить или исправить — пожалуйста, присылайте PR! Обещаю, никто не уйдёт обиженным.
Пользуясь случаем, хочу сказать спасибо нашему соотечественнику Сергею Роженко aka grayface [97], создавшему в нулевые годы десятки утилит для работы с данными «Героев», исходники которых он выложил на GitHub [98]. Сергей, да пребудет с тобой навечно Сила Delphi!
Разобравшись с форматами в начальном приближении, под конец дня я написал самый первый код для клиентской части: 230 строк (под спойлером), отрисовывающих карту «Приключений Жареда Харета» в базовой версии (как на КДПВ [99]). В последующие годы я до того насмотрелся на эту карту, что мог бы по памяти нарисовать стартовый экран, где героя в проходе зажали Троглодиты… но мы отвлеклись.
<!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 [100], Map [101], DOM.Map [102].
Отрисовка вскрыла и первые проблемы, вторая из которых не решена до сих пор:


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

Я уже говорил, что люблю универсальные технологии? Так вот, в HeroWO вообще все данные хранятся в виде пары десятков огромных массивов внутри объектов класса док [104]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 у док [104]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 [79] использовал такую систему вынужденно ради скорости; я же с самого начала положил её в основу проекта, полагая, что она радикально облегчит сериализацию игрового мира. Для примера, конвертер карт оригинальных «Героев» (h3m2json.php [1]) занимает 4 475 строчек, плюс 602 строки с комментариями. В то же время, сохранение и загрузка карты в формате HeroWO — это просто череда вызовов JSON.stringify()/JSON.parse() без какой-либо подготовки данных. Подкупало и то, что наличие единой точки доступа к данным (в лице док [104]ObjectStore) должно было сильно упростить синхронизацию клиентов в многопользовательской игре — и действительно, сейчас там порядка 650 строк (сервер [105], клиент [106]), что в разы меньше, чем один только парсер карт в homm3tools.
Оглядываясь назад, я вижу, что это решение было одним из краеугольных камней, благодаря которому получилось довести дело до выпуска. Я постоянно находил новые выгоды от него — например, хранение данных в виде плоского массива числовых значений потенциально позволяет использовать его напрямую в WebAssemly, что открывает дорогу для оптимизации другого фундаментального механизма HeroWO: калькуляторов игровых эффектов, о которых мы поговорим значительно позже.
Прошло всего 4 дня, а карта уже, считай, готова. График, вроде, выдерживаем, ещё недельку — и ка-ак зарелизим! Казалось бы, что могло пойти не так… 
На днях выйдет вторая часть. Если вам понравилось — пожалуйста, подпишитесь на блог компании [107], где будут публиковаться все остальные части! Ну, и жду бурления конструктивной полемики в комментариях!
herowo.game [8] • Форум [10] • Discord [11] • YouTube [108] • GitHub [109]
Автор: Павел
Источник [110]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/384175
Ссылки в тексте:
[1] h3m2json.php: https://github.com/HeroWO-js/h3m2json
[2] Мультиплеер: https://youtu.be/4FPShDK39uo
[3] Любое разрешение экрана и платформа: https://youtu.be/p8KIeIfkwgU
[4] Неограниченное число участников и размер поля в битве: https://youtu.be/Sv41KuMesQE
[5] вдохновлялся: https://images.gog-statics.com/5af56094055dcfa03f7cc2e69cd78ca85018ed8f7fcdc959e60e3f284d72a69c.jpg
[6] Произвольное число уровней на карте приключений: https://youtu.be/FBiKVuTR9lY
[7] Все исходники под Unlicense на GitHub: https://github.com/HeroWO-js/Workbench
[8] Заходите на herowo.game для игры: https://herowo.game
[9] вот Tutorial: https://herowo.game/#VHV0b3JpYWwv
[10] форум: https://herowo.io/forum
[11] Discord: https://discord.gg/UcGCNhJEUx
[12] Как ещё помочь?: https://forum.herowo.net/t/topic/15
[13] началось всё: https://youtu.be/KLZbbHbeSos?t=29
[14] с хорошо продуманного плана: https://vk.com/video198565620_164224436
[15] задачу трёх тел: https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%B4%D0%B0%D1%87%D0%B0_%D1%82%D1%80%D1%91%D1%85_%D1%82%D0%B5%D0%BB_(%D1%80%D0%BE%D0%BC%D0%B0%D0%BD)
[16] главное: https://habr.com/ru/post/716564
[17] чтобы: https://habr.com/ru/post/725432
[18] не: https://habr.com/ru/post/722716
[19] буквально: https://habr.com/ru/post/720634
[20] идея должна была быть достаточно безумной, чтобы за неё взяться: https://en.wikiquote.org/wiki/Niels_Bohr
[21] «Герои Меча и Магии» в браузере: долго, сложно и невыносимо интересно: https://habr.com/ru/post/354014
[22] heroeswm: https://www.heroeswm.ru
[23] heroesland: https://www.heroesland.com
[24] mlgame: http://mlgame.ru
[25] благополучно почившую в бозе: https://web.archive.org/web/20210303172956/https://clb.heroes-online.com/en-US/news/end-mmho-1
[26] VCMI: https://vcmi.eu
[27] fheroes2: https://github.com/ihhub/fheroes2
[28] кучи: https://play.google.com/store/apps/details?id=com.stgl.global
[29] мобильных: https://play.google.com/store/apps/details?id=com.homm.cgames.app
[30] игр: https://play.google.com/store/apps/details?id=heroes.battle.strategy.card.game
[31] FreeHeroes: https://github.com/mapron/FreeHeroes
[32] mapron: https://habr.com/ru/users/mapron/
[33] heroesworld.ru: https://heroesworld.ru
[34] heroescommunity.com: http://heroescommunity.com
[35] celestialheavens.com: https://www.celestialheavens.com
[36] df2.ru: https://forum.df2.ru/index.php?showforum=4
[37] h3.gg: https://www.h3.gg
[38] Форум на GOG: https://www.gog.com/forum/heroes_of_might_and_magic_series
[39] heroes35.net: http://wforum.heroes35.net
[40] Discord-сервер: https://discord.gg/horn-of-the-abyss-519540110195425280
[41] HotA: https://h3hota.com
[42] сервер мододелов WoG и ERA: https://discord.gg/W3r6Xun
[43] «Физмиг» aka «Физика мира Героев»: https://vk.com/fizmig
[44] схожий проект handbookhmm: https://handbookhmm.ru
[45] maps4heroes.com: https://maps4heroes.com
[46] hommdb.com: http://www.hommdb.com
[47] библиотека идей для новых городов: https://heroes3towns.com
[48] и прочая: http://mmgames.ru
[49] и прочая: https://heroes3wog.net
[50] и прочая: https://heroesportal.net
[51] и прочая: http://www.might-and-magic.ru
[52] и прочая: https://homm3sod.ru
[53] и прочая: http://h3wog.narod.ru
[54] и прочая: http://packa.ucoz.ru
[55] на польском: http://h3.heroes.net.pl
[56] ещё на польском: https://www.acidcave.net
[57] а вот на чешском: https://worldofheroes.cz
[58] даже: http://heroworld.gamerhome.com
[59] на китайском: https://www.h3wog.com
[60] это было: https://habr.com/ru/post/715614
[61] в феврале: https://habr.com/ru/post/711288
[62] не удивительно: https://habr.com/ru/post/716866
[63] статья автора: https://habr.com/ru/post/665654
[64] много: https://www.mirf.ru/games/videogames/20-let-geroyam-mecha-i-magii-3-chem-zhivyot-kultovaya-igra-segodnya/
[65] раз: https://habr.com/ru/post/442158
[66] каждый год: http://creaturequest.com
[67] появляется: https://www.youtube.com/watch?v=ePM7Jkjt6wM
[68] что-то: https://www.songsofconquest.com
[69] принципиально: https://homm3boardgame.com
[70] новое: https://www.fanstratics.com
[71] одном рейтинге игр для ПК: http://www.world-art.ru/games/rating_top.php
[72] провалились: https://club.dns-shop.ru/blog/t-64-videoigryi/35809-istoriya-serii-heroes-of-might-and-magic-ot-rastsveta-do-zakata/
[73] 3 057 звёздами на GitHub: https://github.com/vcmi/vcmi
[74] очень вкусного: https://ihhub.github.io/fheroes2/INSTALL.html
[75] Xposed: https://forum.xda-developers.com/t/xposed-general-info-versions-changelog.2714053/
[76] Sqimitive: https://habr.com/ru/post/239149
[77] если трактористы — женщины!: https://%D0%B1%D0%B0%D1%88%D0%BE%D1%80%D0%B3.%D1%80%D1%84/quote/396626
[78] Sqimitive: https://squizzle.me/js/sqimitive/
[79] lekzd: https://habr.com/ru/users/lekzd/
[80] h3m-map-convertor: https://github.com/lekzd/h3m-map-convertor
[81] homm3tools: https://github.com/potmdehex/homm3tools
[82] демо-сайта: http://homm.lekzd.ru
[83] можете запустить её в HeroWO: https://herowo.game/#QWR2ZW50dXJlcyUyMG9mJTIwSmFyZWQlMjBIYXJldC8
[84] MMArchive: https://github.com/GrayFace/Misc/releases?page=3#hd-f9501864
[85] TxtEdit: https://github.com/GrayFace/Misc/releases?page=3#hd-e1bd7f07
[86] DefPreview: https://github.com/GrayFace/Misc/releases?page=6#hd-02c0dbe4
[87] DefTool: https://github.com/GrayFace/Misc/releases?page=5#hd-7078cf94
[88] adpcm2pcm: https://github.com/HeroWO-js/Workbench/tree/master/tools/adpcm2pcm
[89] Audacity: https://www.audacityteam.org
[90] SoX: https://sox.sourceforge.net
[91] купленном Epic: https://www.epicgames.com/site/en-US/news/epic-acquires-rad-game-tools
[92] RAD Game Tools: http://www.radgametools.com
[93] папка databank: https://github.com/HeroWO-js/Core/tree/master/databank
[94] содержимое архивов и типы используемых файлов: https://github.com/HeroWO-js/Core/blob/master/databank/Formats.txt
[95] графических: https://github.com/HeroWO-js/Core/blob/master/databank/Images.txt
[96] звуковых: https://github.com/HeroWO-js/Core/blob/master/databank/Sounds.txt
[97] Сергею Роженко aka grayface: http://grayface.chat.ru/rus/
[98] GitHub: https://github.com/GrayFace
[99] КДПВ: https://habrastorage.org/webt/2r/fv/5r/2rfv5rdpralxok9zyltejp_0ufa.png
[100] ObjectStore: https://github.com/HeroWO-js/Core/blob/62a8d16f499905b3191658661474405b29f0658b/client/ObjectStore.js#L297-L343
[101] Map: https://github.com/HeroWO-js/Core/blob/62a8d16f499905b3191658661474405b29f0658b/client/Map.js#L349-L357
[102] DOM.Map: https://github.com/HeroWO-js/Core/blob/62a8d16f499905b3191658661474405b29f0658b/client/DOM.Map.js#L533-L538
[103] текущая формула: https://github.com/HeroWO-js/Core/blob/62a8d16f499905b3191658661474405b29f0658b/databank/h3m2herowo.php#L1847-L1861
[104] док: https://github.com/HeroWO-js/Core/blob/62a8d16f499905b3191658661474405b29f0658b/client/ObjectStore.js
[105] сервер: https://github.com/HeroWO-js/Core/blob/62a8d16f499905b3191658661474405b29f0658b/client/RPC.js#L115-L561
[106] клиент: https://github.com/HeroWO-js/Core/blob/62a8d16f499905b3191658661474405b29f0658b/client/RPC.js#L1277-L1453
[107] блог компании: https://habr.com/ru/company/soletude/blog/
[108] YouTube: https://www.youtube.com/@PlayHeroWO
[109] GitHub: https://github.com/HeroWO-js
[110] Источник: https://habr.com/ru/companies/soletude/articles/719280/?utm_source=habrahabr&utm_medium=rss&utm_campaign=719280
Нажмите здесь для печати.