TL;DR для тех, кому некогда читать™:
- Три года реального времени, ~340 дней разработки, 2 423 часа.
- 106 849 строк кода (62% JavaScript, 32% PHP, 6% CSS).
- Собственный парсер формата .h3m (h3m2json.php).
- Неограниченные возможности для создания модификаций.
- Мультиплеер на WebSockets без блокировок — не нужно ждать, пока другой игрок закончит ход или битву.
- Любое разрешение экрана и платформа — хоть 4K, хоть телефоны (но пока нет вёрстки).
- Неограниченное число участников и размер поля в битве (вдохновлялся Age of Wonders).
- Произвольное число уровней на карте приключений.
- Все исходники под Unlicense на GitHub.
- Заходите на herowo.game для игры (вот Tutorial).
- Багов — полно. Обязательно пишите о них на форум, в GitHub или в Discord. Как ещё помочь?
← Часть первая, в которой мы ставим задачу написать «Героев» за месяц, изучаем имеющиеся проекты, знакомимся с
(5) День пятый
Кроим карту
Ещё до начала проекта я решил, что если хочу дожить до его выпуска, то нужно «среза́ть углы» везде, где только это возможно, и делать минимум оптимизаций. Один из «срезанных углов» — это система отрисовки, полностью основанная на DOM (в нынешней версии благодаря усилиям definitelyfakename это уже не совсем так). Это значит, что каждый объект на карте, включая тайлы (землю), представлен отдельным DOM-узлом, причём для земли их число утраивается (один узел на основной карте, один для тумана войны, один на мини-карте). Для карты среднего размера (Medium) без подземелья мы имеем 72×72 тайлов, то есть больше 15 000 узлов. А для самой большой (Extra Large) — больше 60 000! Как тебе такое, Илон Маск?!
Понимаю, что сейчас в меня полетят тапки, а за расход памяти в 150 Мб на вкладку (на два порядка больше, чем в оригинальной игре) и вовсе четвертуют — но я ни о чём не жалею позвольте, цельный движок с медленной отрисовкой лучше, чем недоделанный движок с быстрой отрисовкой! Главное, что код рендеринга карты почти полностью изолирован в двух файлах (докDOM.Map
и докDOM.MiniMap
) и занимает порядка 1 500 строк. Знатоки Canvas, если есть желание потрудиться на благо Эрафии — черканите по любому из адресов!
В этот день, добавив ко вчерашнему коду простенький генератор CSS на основе .hdl (текстовое описание кадров внутри DEF, генерируемое DefPreview), я смог анимировать карту и со сдержанным оптимизмом удивиться, что браузер не встал колом и что после начальной загрузки страница даже не тормозит. Можно было идти дальше.
(8) День восьмой
Вписал карту в игровой интерфейс (ADVMAP.BMP
) — то, что теперь называется докDOM.UI
и докH3.DOM.UI
. Сделал прокрутку с помощью зажатой ЛКМ и подведения курсора к границе карты (как в оригинале). Добавил несколько кнопок для отладки, сетку и показ непроходимых и интерактивных тайлов.
Неожиданно долго (половину дня) делал узорчатую границу (EDG.DEF
) для карты, прокрученной к крайнему положению, но в итоге удалось сформировать правильную картинку для border-image
, так что всё решение уложилось в три строки CSS.
(10) День десятый
Видосики и главное меню
Понимая, что проект уже завтра уйдёт на золото, я по-быстрому и срочно решил сделать главное меню. Но всё оказалось не так просто (никогда такого не было, и вот опять!)…
Не вставая, просидел 11 часов и всё равно не закончил верстать меню — простота его оказалась обманчивой. Правда, половина этого времени ушла на попытки сконвертировать видео. Верный своему стремлению свалить как можно бо́льшую часть работы на браузер, я изначально хотел все анимации (DEF) и ролики (BIK/SMK) делать через <video>
, а не через CSS или GIF. Проблем было две: прозрачность и артефакты.
На MDN есть специальная страница, посвящённая кодекам в браузерах, где говорится, что поддержка альфа-канала есть только в VP8 (на самом деле, в VP9 тоже), причём в VP9 ещё и можно кодировать цвета как RGB. Последнее важно, так как в DEF и BIK, естественно, используется цветовая модель для компьютерных экранов (RGB), а потому желательно избежать преобразований в YUV или иную модель, иначе возникнут искажения.
Перепробовав разные контейнеры для VP9, я нашёл только один, который отображается в браузерах — это WebM. От RGB пришлось отказаться, т.к. ffmpeg не умеет кодировать VP9 с -pix_fmt bgr
, но и невооружённым взглядом было видно, что ролик не такой чёткий, как исходный PNG, а в Firefox <57 у него, к тому же, нет прозрачности. WebM с многообещающим режимом lossless у меня не открылся совсем, причём не только в браузере, но и в VLC. Для сравнения, OGV (Theora) оказался куда ближе к оригиналу, но без прозрачности и тоже не lossless:
Тогда стал смотреть на форматы анимированных изображений. WebP («урезанный» VP8) — самый модный и имеет режим lossless (который даже переключает цветовую модель с YUV на RGB), но я не ждал существенной выгоды в размере относительно PNG, а терять старые браузеры только из-за графики не хотелось (на сегодня, вся статичная графика в PNG занимает 561 Мб, а в WebP — 522 Мб, -7%).
В итоге решил оставить покадровые CSS-анимации для DEF-ов, а ролики собрал в PNG с помощью apngasm (ffmpeg до сих пор не поддерживает APNG). Так и живём.
Кстати, интересный факт: на CD-ROM «Героев» есть два архива, дополняющих архивы установленной игры:
-
Heroes3.snd
— нарративы для кампаний. -
VIDEO.VID
— видео высокой чёткости и видео для кампаний. 111 роликов с разрешением 800×600 (мне кажется, к нам таких мониторов ещё не завезли) — не то что 200×116, которые шли вместе с игрой. Весили они аж целых 272 Мб — почти как все «Герои», и не у каждого в то время нашлась бы такая прорва свободного места…
(13) День тринадцатый
Сделал мини-карту. Долго выяснял, как же игра её рисует. Как и с Z-координатой в главной карте (см. первую статью), очевидной логики не уловил: выводится только часть непроходимых клеток (например, горы, но не мельница), а в подземелье и того меньше (возможно, для удобства восприятия). Не стал в это погружаться — решил, что чем проще, тем лучше. Для сравнения (сверху — оригинал, снизу — HeroWO):
Как и основная карта, мини-карта «рисовалась» через DOM. Расчёт размеров и положений делался на стороне CSS: каждая клетка на мини-карте — это узел со своими left/top и width/height, заданными в em. Только font-size
родительского узла зависел от видимого размера мини-карты на экране, что позволяло легко её масштабировать, меняя только родителя.
Добавил обновление мини-карты при изменении положения объектов в мире.
Сделал первый коммит с кодом проекта в локальный git.
(16) День шестнадцатый
Закончил главное меню — сортировку и фильтр карт, показ информации о выбранной карте и прочее. На всё про всё на меню ушла неделя — и уйдёт ещё одна, уже когда буду делать серверную часть и UI для запуска игры. Выглядит так (вначале экран оригинала, затем он же в HeroWO):
Первые элементы игрового UI
Логический корень работающего игрового движка — это объект класса докContext
, и он всегда ровно один. В этот день я выделил из него класс докScreen
, который отражает экран конкретного игрока. В одиночной игре Screen один, в режиме «горячего стула» (hotseat) их много, а на сервере в многопользовательской игре нет ни одного.
До сих пор все объекты карты были «на одно лицо» — что земля, что шахты, что герои. Начал вводить особые поля для героев и, в качестве теста, сделал список героев игрока в правой панели.
В проекте на данном этапе 19 JavaScript-файлов (2 693 строки, причём 672 — это главное меню), 10 PHP-файлов (1 719 строк: конвертеры графики, текстовых файлов и карт формата «lekzd-json») и 2 CSS-файла (979 строк, причём 757 — это главное меню).
(18) День восемнадцатый
До сих пор для извлечения ресурсов я использовал ResEdit2 за авторством другого нашего соотечественника — Александра Карпенко (написанный — вы не поверите — на Delphi). Внезапно обнаружил, что он неверно экспортирует графику, добавляя к ней один прозрачный столбец слева, из-за чего у меня съехали все смещения, в том числе в многострадальной разметке главного меню. Photoshop такую графику открывал в режиме PIxel Aspect Ratio Correction и выглядела она… своеобразно (см. картинку справа в углу). Ну, я сам виноват — не придал значения тому, что фоновые рисунки имеют подозрительную ширину в 799 пикселей. Пришлось исправлять.
В конце дня добавил узорчатые уголки к основной карте и сделал самые элементарные кнопки в правой панели.
(19) День девятнадцатый
Реактивные элементы UI
В этот день появилась зловещая заметка:
«Переписываю почти всё. Кроме Map и ObjectStore, всё разломал.»
В процессе добавления элементов игрового UI я понял, что этот самый UI нужно строить на основе маленьких реактивных блоков, которые я назвал докDOM.Bits
— «частички». Вывод игровой даты — блок, слушает изменения в поле date объекта докMap
. Вывод уровня удачи героя — блок, вычисляет значение на основе игровых эффектов (о них в следующий раз; не забудьте подписаться на блог, чтобы не пропустить её!) и обновляется по мере надобности. Вывод списка героев — блок, внутри — вложенные блоки (портрет, имя, запас маны). Таким образом, логика отображения конкретного элемента предельно изолируется от родителя, что упрощает разработку и позволяет переиспользовать разные блоки в разных родителях так, что диалоги собираются из кучки блоков, как из конструктора. Такие себе специализированные WebComponents.
Например, в текущей версии окошко с информацией о существе составлено из 10 блоков (зелёные рамки), причём 4 из них используются и в информации о герое:
Я потратил целый вечер на то, чтобы исстрочить два таких листа своими каракулями и прикинуть, сколько блоков потребуется для игры в целом:
Получилось около полутора сотен (в реальности вышло чуть меньше и часть из них стала калькуляторами, о них тоже в другой раз). Когда подумаешь, что 150 блоков способны обрисовать UI целой игры, то это число уже не кажется чем-то большим. Листы со своей черкатнёй я после этого так ни разу и не доставал, но осознание того факта, что объём работ по UI строго конечный, меня тогда здорово успокоило и воодушевило.
На следующий день я выделил код из прежде монолитного докH3.DOM.UI
(UI, специфичный для «Героев 3») в два десятка докDOM.Bits
: ObjectProperty, HeroLuck, Garrison, TownHallLevel…
Вторые «Герои», будучи чисто DOS-овскими, были по определению синхронными — скажем, в момент проигрывания звуковых эффектов игра замирала. Третьи «Герои» таких явных проблем не имели, но внутри всё равно оставались во-многом синхронными. HeroWO же полностью построен на реактивном подходе и событийно-ориентированном движке.
Например, если в оригинале открыть окно с информацией о герое и начать перемещать существа в гарнизоне, то панель в правом нижнем углу основного UI обновится только по закрытии этого окна. В HeroWO же все изменения распространяются моментально. Такая модель всё чрезвычайно усложняет (чего стоят одни только анимации на карте, которые делаются «задним числом» относительно состояния мира), но представить по-настоящему параллельную многопользовательскую игру иначе я не мог.
(23) День двадцать третий
Добавил список городов справа и сменяющиеся информационные панели справа снизу (текущий день и списки игроков и городов), а также новый экран — город, сейчас почти пустой. Добавил новый класс докH3.Rules
, отвечающий за игровую логику; пока что он умеет только увеличивать счётчик дней в конце хода и инициализировать опыт и имена героев и городов случайным образом.
Писал конвертер информации о заклинаниях, навыках героев, артефактах и банках.
Прошло три недели с момента первой отрисовки игровой карты. Сложилась базовая структура (и инфраструктура) проекта в виде иерархии классов и преобразования данных.
В проекте на данном этапе 23 JavaScript-файла (4 457 строки), 10 PHP-файлов (1 842 строки) и 2 CSS-файла (1 270 строк).
(25) День двадцать пятый
Банк данных
Все игровые данные HeroWO хранятся в так называемом databank — банке данных, представленном множеством файлов JSON и CSS. До сих пор он генерировался одной большой функцией на 2 618 строчек (правда, состоящей из десятка изолированных частей); сегодня начал её делить на отдельные функции, отвечающие за конкретный кусок данных. Значения, отсутствующие в исходных текстовиках «Героев», положил в файлы databank-*.php
, которые подключаю в основной код.
(27) День двадцать седьмой
Сервер и синхронизация
Изначальный план был написать оставшиеся докDOM.Bits
, но я решил это отложить и взяться за механизм синхронизации (основа для многопользовательской игры). Его уже можно было отлаживать, имея уведомления об изменениях объектов на карте и докDOM.Bits
, которые на них реагируют, и в то же время легко подкручивать, пока кода ещё мало.
Серверный движок построен на том же коде, что и клиентская часть, а потому работает на Node.js. Транспорт для связи с клиентами — WebSockets, посредством референсной реализации в NPM-пакете ws. Клиент и сервер обмениваются сообщениями формата JSON-RPC. Сам сервер — точно такой же экземпляр движка, но без экрана (докScreen
), и только он может изменять состояние игрового мира напрямую. Клиенты уведомляются об изменениях и накатывают «патчи», поддерживая состояние локального мира идентичным серверному.
Когда игрок выполняет действие, изменяющее мир (например, передвигает героя или покупает существ), делается RPC — асинхронный вызов «удалённой» команды. «Удалённый» пишу в кавычках, потому как в одиночной игре эта команда вызывается на самом клиенте и изменяет состояние мира самого клиента. Благодаря этому, бо́льшая часть движка знать не знает, в каком режиме она функционирует. За счёт этого имеем меньше кода и асинхронность «из коробки».
Например, если мы перемещаем отряд существ в гарнизоне героя из одной позиции в другую, то клиент посылает такое сообщение на сервер:
{
jsonrpc: "2.0",
id: "p546",
method: "garrison",
params: {do: "swap", to: 2949, toSlot: 1, from: 2949, fromSlot: 2}
}
Здесь, p546 — ID, по которому клиент сможет связать ответ с запросом, а garrison — имя RPC-метода на сервере. Подметод swap меняет два отряда местами (в противоположность, например, split), причём to и from (ID объектов с гарнизонами) совпадают — это наш герой.
do_garrison()
выглядит примерно так:
do_garrison: function (args) {
switch (args.do) {
case 'swap':
var oldFrom = from.removeAtCoords(args.fromSlot, 0, 0, 0)
var oldTo = to.removeAtCoords(arg.toSlot, 0, 0, 0)
oldTo && from.addAtCoords(arg.fromSlot, 0, 0, oldTo)
to.addAtCoords(arg.toSlot, 0, 0, oldFrom)
return new Common.Response({status: true, result: null})
Сервер отвечает серией из двух сообщений:
{
event: "batch",
id: 10,
data: {
phase: 3,
batched: [
{
locator: ["objects", 188760, 0],
events: [
{method: "removeAtContiguous", args: [4, 0]},
{method: "addAtContiguous", args: [2, [11, 2]]}
]
}
]
}
}
{
event: "jsonrpc",
id: 11,
data: {
jsonrpc: "2.0",
id: "p546",
result: null
}
}
Первое — это «diff» в состоянии мира, накатив который клиент актуализирует свою локальную копию мира. phase 3 означает, что изменение произошло уже после полной загрузки карты, так что если клиент ещё не закончил загружаться — diff нужно отложить. locator задаёт путь до объекта, который был изменён; в нашем случае объект находится во вложенном хранилище объекта по индексу 188760 на слое 0 в коллекции objects. Индекс 188760 при длине схемы в 64 элемента обозначает поле номер 24 (188760 % 64) объекта номер 2 949 (188760 / 64), то есть поле garrison (вложенное хранилище) нашего героя. Дальше у этого хранилища вызывается два метода: один удаляет объект с индексом первого поля = 4 (при длине схемы garrison в 2 элемента получаем слот номер 2, т.е. fromSlot) и добавляет объект по индексу 2 (то есть 1, он же toSlot), причём значения полей нового объекта выставляются в [11, 2]
, что при схеме garrison = {creature: 0, count: 1}
равнозначно {creature: 11, count: 2}
(11 — ID существа под названием Champion, один из начальных отрядов Жареда).
Второе сообщение — это асинхронный ответ на вызов метода в запросе номер p546. Метод garrison завершил работу и ничего определённого не вернул (null).
Клиент отвечает подтверждением, обозначающим, что сервер может забыть все сообщения с id меньшим 12 и не пересылать их, если связь оборвётся:
{
jsonrpc: "2.0",
method: "ack",
params: {id: 11}
}
На этой же основе уже перед самым выпуском я реализовал «локальный игровой сервер» в виде отдельного потока (WebWorker) для одиночной игры. Это повысило отзывчивость страницы за счёт выполнения вычислений «на стороне». Понадобилось лишь добавить новый транспорт (postMessage()
вместо WebSocket
) и точку входа для new Worker()
(докEntry.Worker
).
(29) День двадцать девятый
Закончил писать код синхронизации, но запускать ещё не пробовал. За день написал 800 строчек. Сделал всё, что хотел — даже такую вещь, как быстрое переподключение клиента в случае обрыва связи: вместо того, чтобы снова скачивать всё состояние мира (как при первом подключении), клиент может сообщить секретную строку («ticket») и продолжить получать только те diff-ы, которые он ещё не видел (механизм, аналогичный ACK в TCP; ответ клиента в примере выше — как раз об этом).
В этот день возникло ощущение, что вместе с банком данных, докDOM.Bits
и синхронизацией я закончил описывать «круг» самых важных подсистем движка и остается лишь добавлять «мясо» на готовый «скелет». Ну, это я был оптимист…
(35) День тридцать пятый
Закончил тестировать и делать ревью сервера. Отлаживать асинхронный код — занятие утомительное, но, к своему стыду, я ещё и делал это по-старинке через «print», так как про наличие удалённой отладки в Chrome я и слыхом не слыхивал. Впрочем, «старинки» хватило ненадолго — я понял, что серьёзные люди так работать не могут, и пошёл искать альтернативу (и нашёл её в документации).
Код синхронизации, написанный за последние дни, составил 1 725 строчек. В проекте на данном этапе 34 JavaScript-файла (7 026 строк), 12 PHP-файлов (3 890 строк, включая 344 строки в databank-*.php
) и 2 CSS-файла (1 557 строк). Размер кода перевалил за 10 000 строчек.
35-й день, налицо небольшое отставание от графика, ну ничего, пара-тройка дней — не проблема, это ж не три года. Хотя подождите…
herowo.game • Форум • Discord • YouTube • GitHub
Это ещё не конец. Если вам понравилось — пожалуйста, подпишитесь на блог компании, где будут публиковаться все остальные части! Ну, и жду бурления конструктивной полемики в комментариях!
tas
Понравилось! Читал и следил за скролбаром страницы опасаясь, что дни закончатся раньше, чем я увижу сообщение о релизе. Так и получилось - теперь буду ждать продолжения...
ProgerXP Автор
Спасибо! Дней еще много, статей предстоит немало.