Скриншот пользователя Mitchfork, победивший в соревнованиях 2021 Screenshot of the Year
Я портировал Zelda Classic (игровой движок, основанный на первой части Zelda) в веб. В него можно поиграть здесь, хватайте геймпад, если он у вас есть!
Это приложение PWA, так что можно его даже установить.
Zelda Classic
Редактор квестов Zelda Classic под названием ZQuest
Zelda Classic — это игровой движок, изначально созданный для воссоздания и модифицирования первой Legend of Zelda. Этому движку уже более двадцати лет. Движок развивался и теперь поддерживает гораздо больше функций, чем оригинальная игра, на нём уже создано более шестисот игр. Сообщество называет их квестами.
Многие из них стали духовными потомками оригинала, иногда с улучшенной графикой, но всё равно узнаваемыми как игры в стиле Zelda. Все они имеют очень разную сложность, качество и длительность. Стоит сказать, что некоторые просто ужасны, так что будьте внимательны и смотрите на рейтинги.
Если вы фанат оригинальных двухмерных игр Zelda, то многие квесты Zelda Classic определённо стоят вашего внимания. Есть игры длительностью более двадцати часов, с большими мирами и увлекательными уникальными подземельями. Сегодня движок поддерживает скрипты, и многие разработчики воспользовались этим, чтобы расширить свои возможности: в некоторых квестах реализованы классы персонажей, онлайн-режимы или достижения; при этом всё это сделано на движке, который должен был воссоздавать оригинальную Zelda.
Однако самые новые версии Zelda Classic поддерживали только Windows… до недавнего времени!
Переходим в веб
Я потратил последние два месяца (около 150 часов) на портирование Zelda Classic, чтобы его можно было запускать в веб-браузере.
Квестов очень много, ниже представлена лишь небольшая выборка. Нажмите на любую из ссылок, чтобы перейти к квесту:
BS Zelda 1st Quest
Link’s Quest for the Hookshot 2
Hero of Dreams
Go Gollab: The Conflictions of Morality
Legend of Link: The New Legacy
Castle Haunt II
Надеюсь, мои труды позволят расширить аудиторию Zelda Classic. Это была сложная работа, далёкая от моей зоны комфорта в веб-разработке, и я многое узнал о WebAssembly, CMake и многопоточности. Параллельно работе я нашёл баги в нескольких проектах, приложил должные усилия к их устранению, и даже предложил внести изменения в спецификацию HTML.
Портируем Zelda Classic в веб
В оставшейся части статьи я расскажу о техническом процессе портирования Zelda Classic в веб.
Если вас интересуют подробности, то я выложил онлайн свои ежедневные заметки. Я впервые вёл подобные заметки, и оказалось, что этот процесс существенно улучшил мою рабочую память и помог в написании этой статьи.
Работа
Emscripten
Emscripten — это компиляторный тулчейн для сборки C/C++ в WebAssembly. Он использует clang
для преобразования получившегося байт-кода LLVM в Wasm. Недостаточно просто скомпилировать код в Wasm – Emscripten также предоставляет функции среды исполнения Unix, реализуя их при помощи JavaScript/Web API (например: реализации большинства системных вызовов; файловая система в памяти или с поддержкой IndexedDB; поддержка pthreads при помощи Web Workers). Так как многие проекты C/C++ собираются при помощи Make и CMake, Emscripten также предоставляет инструментарий для взаимодействия с этими инструментами: emmake
и emcmake
. Чаще всего, если программа на C/C++ портируема, её можно собрать с помощью Emscripten и запустить в браузере, однако, скорее всего, придётся внести изменения для соответствия основному циклу браузера.
Если вы разрабатываете Wasm-приложение, то вам не обойтись без расширения Chrome DevTools DWARF. Информацию о его использовании см. в этой статье. Оно работает превосходно. Чтобы получить наилучшие результаты, возможно, придётся отказаться от всех оптимизаций. Но даже без прохода оптимизации я сталкивался со случаями, когда некоторые кадры трассировки стека вызовов были очевидно неверными, поэтому иногда приходилось использовать отладку с printf.
Приступаем к работе
Zelda Classic написан на C++ и использует Allegro — низкоуровневую кросс-платформенную библиотеку для управления окнами, отрисовки экрана, воспроизведения звуков и т. п. На самом деле, движок использует Allegro 4, выпущенный примерно в 2007 году. Allegro 4 нельзя напрямую компилировать с помощью Emscripten, однако Allegro 5 можно. Эти две версии сильно отличаются, но, к счастью, существует библиотека-адаптер под названием Allegro Legacy, позволяющая собрать приложение Allegro 4 с помощью Allegro 5.
Вот и первое препятствие — Zelda Classic нужно портировать на Allegro 5, и его CMakeLists.txt нужно изменить так, чтобы собирать allegro из исходников.
Allegro 5 может поддерживать сборку при помощи Emscripten, поскольку может использовать в качестве бэкенда SDL, которую Emscripten хорошо поддерживает.
Прежде чем приступать к работе, мне нужно было восполнить свои пробелы в знаниях о CMake и Allegro.
Изучаем CMake, Allegro и Emscripten
Заявляется, что Allegro поддерживает Emscripten, но я хотел убедиться в этом самостоятельно. К счастью, у библиотеки есть инструкции о том, как выполнять сборку с Emscripten. Мои первые PR были отправлены разработчикам Allegro, чтобы они улучшили эту документацию.
Я потратил несколько часов из-за различий между bash и zsh.
Потом я нашёл интересный пример программы, демонстрирующей замену палитр — она кодирует битовую карту как индексы произвольного набора цветов, которые можно менять во время выполнения. Но после сборки Emscripten она не заработала. Чтобы попрактиковаться с Allegro, я поработал над усовершенствованием этого примера.
Фрагментный шейдер:
uniform sampler2D al_tex;
uniform vec3 pal[256];
varying vec4 varying_color;
varying vec2 varying_texcoord;
void main()
{
vec4 c = texture2D(al_tex, varying_texcoord);
int index = int(c.r * 255.0);
if (index != 0) {
gl_FragColor = vec4(pal[index], 1);
}
else {
gl_FragColor = vec4(0, 0, 0, 0);
};
}
Allegro передаёт шейдеру текстуру битовой карты как al_tex
, и в этой программе битовая карта — это просто серия чисел в диапазоне 0-255. В качестве входящих данных к шейдеру подключается палитра цветов pal
, и в среде выполнения программа заменяет палитру, меняя цвета, которые рендерит шейдер. Здесь есть две ошибки, из-за которых шейдер не работает в WebGL:
- Отсутствует объявление точности. В WebGL это не является опциональным. Исправить это очень просто — достаточно добавить
precision mediump float;
- Для индексирования массива используется непостоянное выражение. WebGL не поддерживает этого, поэтому нужно полностью изменить структуру шейдера. Эта проблема была более сложной, поэтому я просто укажу ссылку на PR
Получившаяся программа выложена здесь.
Оказалось, что знания о замене палитр в Allegro 5 не пригодятся при обновлении Allegro для Zelda Classic, хотя изначально я думал, что они нужны. Тем не менее, это позволило мне познакомиться с библиотекой.
Далее я захотел написать простой CMakeLists.txt
, в котором мог бы разобраться, он должен был собирать Allegro из исходников и поддерживать сборку при помощи Emscripten.
Emscripten поддерживает сборку проектов, сконфигурированных при помощи CMake через
emcmake
— небольшой программы, конфигурирующей тулчейн Emscripten CMake. По сути, командаemcmake cmake
конфигурирует сборку так, чтобы она использовала в качестве компилятораemcc
.
Я потратил много времени на чтение разных туториалов по CMake, изучение реальных CMakeLists.txt
и их построчный разбор. Документация CMake очень пригодилась при этом процессе. В конечном итоге, я получил следующее:
https://github.com/connorjclark/allegro-project/blob/main/CMakeLists.txt
cmake_minimum_required(VERSION 3.5)
project (AllegroProject)
include(FetchContent)
FetchContent_Declare(
allegro5
GIT_REPOSITORY https://github.com/liballeg/allegro5.git
GIT_TAG 5.2.7.0
)
FetchContent_GetProperties(allegro5)
if(NOT allegro5_POPULATED)
FetchContent_Populate(allegro5)
if (MSVC)
set(SHARED ON)
else()
set(SHARED OFF)
endif()
set(WANT_TESTS OFF)
set(WANT_EXAMPLES OFF)
set(WANT_DEMO OFF)
add_subdirectory(${allegro5_SOURCE_DIR} ${allegro5_BINARY_DIR} EXCLUDE_FROM_ALL)
endif()
add_executable(al_example src/main.c)
target_include_directories(al_example PUBLIC ${allegro5_SOURCE_DIR}/include)
target_include_directories(al_example PUBLIC ${allegro5_BINARY_DIR}/include)
target_link_libraries(al_example LINK_PUBLIC allegro allegro_main allegro_font allegro_primitives)
# Эти файлы include обычно копируются в нужные места целевой платформой
# установки allegro, но мы делаем это вручную.
file(COPY ${allegro5_SOURCE_DIR}/addons/font/allegro5/allegro_font.h
DESTINATION ${allegro5_SOURCE_DIR}/include/allegro5
)
file(COPY ${allegro5_SOURCE_DIR}/addons/primitives/allegro5/allegro_primitives.h
DESTINATION ${allegro5_SOURCE_DIR}/include/allegro5
)
Код мог быть проще, но
CMakeLists.txt
Allegro требует некоторых модификаций, чтобы его можно было использовать как зависимость.
Изначально я попробовал использовать ExternalProject
CMake вместо FetchContent
, но это было проблематично с Emscripten, поскольку внутри него используется cmake
, и, похоже, он не знает о тулчейне, предоставляемом emcmake
. Не знаю, почему не удалось заставить его работать, но мне известно, что FetchContent
новее и с ним мне повезло больше.
Allegro Legacy
Allegro 4 и 5 можно считать совершенно разными библиотеками:
- Был переписан практически каждый API, и не один в один
- A4 использует для событий опросы, а A5 — очереди/циклы событий
- A4 поддерживает только программный рендеринг и напрямую поддерживает палитры (что активно используется в ZC); A5 поддерживает шейдеры/рендеринг с GPU-ускорением (однако в нём нет манипуляций с палитрами)
- И, что самое важное, только A5 можно скомпилировать с помощью Emscripten (это очень легко благодаря поддержке SDL)
Для замены вызовов API A4 на API A5, по сути, нужно переписывать код, а учитывая размер Zelda Classic, этот вариант отпадал. К счастью, здесь на помощь пришла Allegro Legacy.
Для поддержки многоплатформенности Allegro абстрагирует всё связанное с операционной системой в «системный драйвер». Он существует для каждой поддерживаемой платформы, реализующей низкроуровневые операции наподобие доступа к файловой системе, управления окнами и т. п. Allegro Legacy наводит мосты между A4 и A5, создавая системный драйвер, использующий A5 для реализации системных интерфейсов A4. Иными словами, Allegro Legacy — это просто A4, использующая A5 в качестве драйвера. Все файлы в src
— это просто A4 (с небольшими модификациями), за исключением папки a5
, которая обеспечивает реализацию A5.
Вот полная архитектура запуска Zelda Classic в браузере:
Я устранил или обошёл баги в каждом слое этой структуры.
Воспользовавшись новообретёнными знаниями о CMake для настройки CMakeLists.txt
Zelda Classic, я собрал Allegro 5 и Allegro Legacy из исходников. Для использования Allegro Legacy практически ничего не пришлось делать. Поначалу я боролся с ошибкой компоновщика «unresolved symbol» для функции, которая, как я был уверен, была включена в компиляцию, но это оказалось простым недосмотром в файле заголовка. Я не специалист в C/C++, поэтому для отладки понадобилась куча времени!
После того, как всё скомпоновалось и компиляция завершилась успешно, Allegro Legacy просто работала, хоть мне и пришлось устранить несколько мелких багов, связанных с вводом мышью и путями к файлам.
Я отправил PR для апгрейда до Allegro 5 в репозиторий Zelda Classic, но, скорее всего, его не объединят в ветку до будущего основного релиза.
Начинаем собирать Zelda Classic с помощью Emscripten
Хотя Zelda Classic теперь работал с A5 и собирал её из исходников, для музыки по-прежнему использовалось несколько уже собранных библиотек. Я не хотел пока разбираться с этим, поэтому перекрыл слой музыки функциями-заглушками, чтобы всё остальное продолжало компоноваться Emscripten.
zcmusic_fake.cpp
#include
#include "zcmusic.h"
int32_t zcmusic_bufsz = 64;
bool zcmusic_init(int32_t flags) { return false; }
bool zcmusic_poll(int32_t flags) { return false; }
void zcmusic_exit() {}
ZCMUSIC const *zcmusic_load_file(char *filename) { return NULL; }
ZCMUSIC const *zcmusic_load_file_ex(char *filename) { return NULL; }
bool zcmusic_play(ZCMUSIC *zcm, int32_t vol) { return false; }
bool zcmusic_pause(ZCMUSIC *zcm, int32_t pause) { return false; }
bool zcmusic_stop(ZCMUSIC *zcm) { return false; }
void zcmusic_unload_file(ZCMUSIC *&zcm) {}
int32_t zcmusic_get_tracks(ZCMUSIC *zcm) { return 0; }
int32_t zcmusic_change_track(ZCMUSIC *zcm, int32_t tracknum) { return 0; }
int32_t zcmusic_get_curpos(ZCMUSIC *zcm) { return 0; }
void zcmusic_set_curpos(ZCMUSIC *zcm, int32_t value) {}
void zcmusic_set_speed(ZCMUSIC *zcm, int32_t value) {}
Zelda Classic считывает с диска различные файлы конфигураций, в том числе файлы данных, содержащие большие элементы, например, MIDI. Emscripten может упаковывать такие данные вместе со средами Wasm с помощью флага --preload-data
. Эти файлы могут быть довольно большими (zc.data
занимает примерно 9 МБ), поэтому лучше всего использовать стратегию долговременного кэширования: --use-preload-cache
— это удобная функция Emscripten, кэширующая этот файл в IndexedDB. Однако используемый ею ключ уникален для каждой сборки, поэтому любое развёртывание делает кэш недействительным для всех пользователей. Это плохо, но существует простой хак, позволяющий использовать хэшированный контент:
# См. https://github.com/emscripten-core/emscripten/issues/11952
HASH=$(shasum -a 256 module.data | awk '{print $1}')
sed -i -e "s/\"package_uuid\": \"[^\"]*\"/\"package_uuid\":\"$HASH\"/" module.data.js
if ! grep -q "$HASH" module.data.js
then
echo "failed to replace data hash"
exit 1
fi
Кроме того, я отправил PR в Emscripten, чтобы исправить приведённую выше проблему.
Да будут потоки
Сразу после того, как у меня удалось собрать Zelda Classic с помощью Emscripten и запустить его в браузере, я столкнулся со страницей, которая не делает ничего, кроме того, что приводит к зависанию основного потока. Включив паузу в DevTools, я увидел проблему:
static BITMAP * a5_display_init(int w, int h, int vw, int vh, int color_depth)
{
BITMAP * bp;
ALLEGRO_STATE old_state;
int pixel_format;
_a5_new_display_flags = al_get_new_display_flags();
_a5_new_bitmap_flags = al_get_new_bitmap_flags();
al_identity_transform(&_a5_transform);
bp = create_bitmap(w, h);
if(bp)
{
if(!_a5_disable_threaded_display)
{
_a5_display_creation_done = 0;
_a5_display_width = w;
_a5_display_height = h;
_a5_screen_thread = al_create_thread(_a5_display_thread, NULL);
al_start_thread(_a5_screen_thread);
while(!_a5_display_creation_done); // <<<<<<<<<<<<<<<<<< Зависание происходит здесь!
}
else
{
if(!_a5_setup_screen(w, h))
{
return NULL;
}
}
gfx_driver->w = bp->w;
gfx_driver->h = bp->h;
return bp;
}
return NULL;
}
Этот паттерн зависающего цикла while проблематичен, поскольку он впустую тратит циклы ЦП. Однако на самом деле это вполне нормально, поскольку ожидается, что код инициализации должен завершиться быстро. В общем случае предпочтительна условная переменная, чтобы позволить потоку «уснуть», пока не начнёт меняться важное для него состояние.
Emscripten может собирать многопоточные приложения, работающие в вебе, при этом он использует Web Workers и SharedArrayBuffer
, но по умолчанию он выполняет сборку без поддержки потоков, поэтому всё происходит в основном потоке.
Чтобы глубоко изучить потоки в Wasm, прочитайте эту статью.
SharedArrayBuffer
требует задания особых заголовков ответов, даже для localhost. Проще всего это сделать при помощиstattik
Пола Айриша: достаточно выполнитьnpx statikk --port 8000 --coi
В приведённом выше случае создаётся поток, который должен мгновенно задавать _a5_display_creation_done
, однако из-за отсутствия потоков этого не происходит, поэтому основной поток зависает навечно.
Очевидно было, что мне нужно включить поддержку pthread
.
Также я понял, что лучше будет включить и PROXY_TO_PTHREAD
, перемещающий основной поток приложения в pthread, он же web worker (вместо основного потока браузера), однако это решение оказалось тупиковым из-за различных неожиданных проблем с SDL, что означает отсутствие поддержки этого параметра.
Я почти заставил
PROXY_TO_PTHREAD
работать, но этого было недостаточно.
Вместо этого мне пришлось добавить rest(0)
во многие места, где Zelda Classic ожидает основной поток приложения; в противном случае функция ASYNCIFY
Emscripten не имела бы возможности получить основной поток в браузере, что привело бы к зависанию страницы. Например, этот код проблематично выполнять в основном потоке:
do
{
}
while(gui_mouse_b());
потому что ввод мышью может регистрироваться только когда управление находится у основного потока браузера. rest(0) устраняет зависание, возвращая управление браузеру при помощи ASYNCIFY:
do
{
// ASYNCIFY сохранит стек, передаст его браузеру
// (обрабатывающему пользовательский ввод или рендеринг), а затем
// восстановит стек и продолжит работу.
rest(0);
}
while(gui_mouse_b());
О мьютексах и deadlock
Самой сложной проблемой, с которой я столкнулся при выполнении проекта, стала отладка deadlock. Несколько дней работы закончились ничем, я разбирался по логам, когда блокировка получалась/освобождалась и каким потоком (огромная трата времени!).
Со временем я осознал, что мне нужно перестать отлаживать запутанный код программы и попытаться воспроизвести проблему с нуля.
SDL предоставляет интерфейс для мьютексов, который в Unix использует pthread
. Очевидно, некоторые платформы не поддерживают рекурсивных мьютексов, таким образом позволяя потоку блокировать один и тот же мьютекс несколько раз и освобождая блокировку только тогда, когда она соответствует равному количеству разблокировок. Для поддержки платформ без этой функциональности SDL имитирует её.
/* Блокируем мьютекс */
int
SDL_LockMutex(SDL_mutex * mutex)
{
#if FAKE_RECURSIVE_MUTEX
pthread_t this_thread;
#endif
if (mutex == NULL) {
return SDL_InvalidParamError("mutex");
}
#if FAKE_RECURSIVE_MUTEX
this_thread = pthread_self();
if (mutex->owner == this_thread) {
++mutex->recursive;
} else {
/* Порядок операций важен.
Мы задаём id блокирующего потока после получения блокировки,
поэтому разблокировка из других потоков будет невозможна.
*/
if (pthread_mutex_lock(&mutex->id) == 0) {
mutex->owner = this_thread;
mutex->recursive = 0;
} else {
return SDL_SetError("pthread_mutex_lock() failed");
}
}
#else
if (pthread_mutex_lock(&mutex->id) != 0) {
return SDL_SetError("pthread_mutex_lock() failed");
}
#endif
return 0;
}
После того, как я понял, что deadlock не происходит, когда не используются условные переменные, мне удалось создать небольшое воспроизведение, приводившее к deadlock при работе с Emscripten, но не при сборке для Mac. Я сообщил о баге разработчикам SDL, и даже предложил патч для улучшения кода имитации рекурсивного мьютекса, (по крайней мере, устраняющий мой deadlock), но оказалось, что смешение условных переменных и рекурсивных мьютексов — это очень плохая идея, и в общем случае её невозможно реализовать верно.
Постепенно я осознал странность того, что Emscripten не поддерживает рекурсивные мьютексы. После создания короткого примера программы я определил, что на самом деле он их поддерживает. Оказалось, что проблема заключалась в том, что конфигурация заголовка SDL для Emscripten не указывает, что рекурсивные мьютексы поддерживаются.
Делаем систему полнофункциональной
Воспроизведение MIDI через Timidity
Файлы .qst
Zelda Classic содержат MIDI, но браузеры не могут напрямую воспроизводить файлы MIDI. Чтобы синтезировать звук из файла MIDI, нужны:
- база данных сэмплов звуков
- код для интерпретации различных команд MIDI, например, включения или отключения нот
Emscripten поддерживает различные форматы аудио при помощи SDL_mixer
, сконфигурированного через SDL2_MIXER_FORMATS
. Однако поддержка MIDI там отсутствует. К счастью, SDL_mixer
поддерживает воспроизведение MIDI (оно использует Timidity). Настроить систему портов Emscripten, позволявшую при запросах включать поддержку Timidity, было очень легко.
В качестве звуковых сэмплов я взял бесплатные сэмплы под названием freepats. Изначально я добавил их в файл данных предварительной загрузки Wasm, но они довольно большие (больше 30 МБ), поэтому лучше загружать отдельные сэмплы по сети по мере их запроса. Я знал, что существует форк Timidity, выполняющий именно эту задачу, поэтому изучал его работу. При загрузке файла MIDI этот форк проверяет все инструменты, которые используются в композиции, и фиксирует в логе, какие из них отсутствуют. Затем код на JS проверяет этот лог, запрашивает отсутствующие сэмплы, и перезагружает данные. По сути, я сделал то же самое, но внутри Timidity/EM_JS
.
Эти скачивания сэмплов тормозят игру (но не основной поток браузера!), пока не будут завершены полностью, что не очень мешает, когда начинаешь квест, но может быть очень раздражающим, когда добираешься до новой области, где воспроизводится композиция с новыми инструментами MIDI. Чтобы ситуация стала более терпимой, я написал функцию fetchWithProgress
, отображающую полосу прогресса в заголовке страницы.
Хотя библиотека freepats очень приятна (она свободна, мала по размерам и имеет хорошее качество), в ней отсутствуют многие инструменты. Чтобы заполнить пробелы, я нашёл звуковые файлы GUS из 90-х на сайте, посвящённом моддингу DOOM. На этой странице есть комментарий о том, что PPL160 обладают ещё более высоким качеством, поэтому я нашёл и их тоже. Я не очень доволен результатом объединения всех этих разношёрстных инструментов. Уверен, ситуацию можно улучшить, но, по крайней мере, ни в каких файлах MIDI нет отсутствующих инструментов.
Музыка работает, но нет звуковых эффектов?
Zelda Classic использует разные каналы вывода для музыки и SFX, что довольно стандартно для игр. Особенно потому, что иногда нужно сэмплировать их с разными частотами, то есть для этого нельзя использовать одинаковый канал вывода. Музыка обычно сэмплируется с более высокой частотой из соображений качества, что требует больше времени на обработку, но это нормально, потому что задержка буфера — не такая уж большая проблема, если вы только не синхронизируете музыку с видео или не выполняете какую-то другую подобную задачу. SFX обычно сэмплируются с меньшей частотой, потому что звуковой эффект нужно воспроизвести как можно быстрее для реакции на геймплей.
Благодаря поддержке MIDI на главном экране теперь играет музыка, но SFX не воспроизводятся. Я скомпилировал пример работы со звуком Allegro ex_saw
, который, как я знал, уже работал с Emscripten, потому что пример на Wasm работал. Однако при локальной сборке ничего не воспроизводилось, то есть я нашёл ещё один баг в Allegro, который нужно устранить.
Я добавил несколько printf в SDL_SetError
и заметил, что когда Allegro вызывала SDL_Init(SDL_INIT_EVERYTHING)
, он выдавал ошибку "SDL not built with haptic support"
, после чего SDL начинала всё ломать! SDL не удавалось настроить подсистему тактильной обратной связи, потому что она не предоставляет для неё Emscripten-реализацию. А поскольку Allegro инициализировала SDL, запрашивая всё, SDL сопротивлялась. Это не объясняет того, почему всё работало раньше, но не сегодня; я выполнил git blame
для функции SDL_Init
и увидел, что недавно было внесено изменение, закрывающее всё при любых ошибках подсистем. Загадка разгадана, я отправил PR разработчикам Allegro, чтобы они это исправили.
Web имеет Vibration API (для вибрации мобильного устройства) и экспериментальную поддержку тактильной обратной связи геймпада, то есть SDL определённо может их поддерживать.
Теперь при локальной сборке пример ex_saw
работал, но SFX всё равно не воспроизводились в Zelda Classic. Потратив время на отладку с помощью printf, я заметил, что SDL не удаётся открыть второй аудиоканал для SFX. Странно… Я открыл реализацию аудио SDL для Emscripten, и моё внимание привлекла переменная OnlyHasDefaultOutputDevice
:
static SDL_bool
EMSCRIPTENAUDIO_Init(SDL_AudioDriverImpl * impl)
{
SDL_bool available, capture_available;
/* Задаются указатели функций */
impl->OpenDevice = EMSCRIPTENAUDIO_OpenDevice;
impl->CloseDevice = EMSCRIPTENAUDIO_CloseDevice;
impl->OnlyHasDefaultOutputDevice = SDL_TRUE;
// ...
Я подумал «ну не может же это сработать», присвоил ей значение SDL_FALSE
и… сработало! Я сообщил об этом баге здесь. Неочевидно, что это является правильным способом устранения проблемы, поэтому какое-то время это не будет ресолвиться в SDL. Что приводит нас к следующей теме…
Хакинг скриптов сборки
При исправлении бага в зависимости обычно нужно подождать, прежде чем можно будет нормально использовать новую версию этой зависимости. Это не проблема, поскольку есть и другие способы использования неофициальной версии зависимости:
- Менеджеры пакетов можно настроить так, чтобы они указывали на конкретный форк или коммит
- Можно самому поставлять зависимость, сделав её частью системы контроля версий своего проекта
- Можно поддерживать набор diff-патчей, применяющихся поверх официального релиза
Есть и путь для ленивых: сначала я разобрался с возможностями команд sed
. я буду находить баг в зависимости, разбираться, как использовать sed
, чтобы починить его локально, вставлять его в свой скрипт сборки, и оставлять заметку, что нужно через какое-то время отправить исправление бага в апстрим.
# Временные решения, пока различные проблемы не будут решены в апстриме.
if [ ! -d "$EMCC_CACHE_DIR/ports/sdl2" ]
then
# Проверяем, что исходный код SDL был скачан.
embuilder build sdl2
fi
# Нужно вручную удалить библиотеку SDL, чтобы заставить Emscripten пересобрать её.
rm -rf "$EMCC_CACHE_LIB_DIR"/libSDL2.a "$EMCC_CACHE_LIB_DIR"/libSDL2-mt.a
# См. https://github.com/libsdl-org/SDL/pull/5496
if ! grep -q SDL_THREAD_PTHREAD_RECURSIVE_MUTEX "$EMCC_CACHE_DIR/ports/sdl2/SDL-release-2.0.20/include/SDL_config_emscripten.h"; then
echo "#define SDL_THREAD_PTHREAD_RECURSIVE_MUTEX 1" >> "$EMCC_CACHE_DIR/ports/sdl2/SDL-release-2.0.20/include/SDL_config_emscripten.h"
fi
# SDL emscripten audio указывает только одно устройство вывода звука по умолчанию, но оказывается,
# что это можно игнорировать и всё работает. Без этого будут воспроизводиться только SFX,
# а MIDI будут выдавать ошибку при открытии идентификатора аудиоустройства.
# См. https://github.com/libsdl-org/SDL/issues/5485
sed -i -e 's/impl->OnlyHasDefaultOutputDevice = 1/impl->OnlyHasDefaultOutputDevice = 0/' "$EMCC_CACHE_DIR/ports/sdl2/SDL-release-2.0.20/src/audio/emscripten/SDL_emscriptenaudio.c"
И это только изменения в SDL. Для Allegro нужно было проделать ещё больше работы…
Благодаря тому, что поначалу я не усложнял, развитие продолжалось, но когда изменения стали больше, чем просто модификация одной-двух строк, этот процесс стал неудобным. В результате я создал простую систему: довольно простое использование git diff
и patch
. Раздражало, что для использования патчей портов Emscripten требовалось очищать кэш, но всё было не так уж плохо. Вот как всё выглядит в целом:
#!/bin/bash
# Очень простая система патчинга. Поддерживает только по одному патчу на папку.
# Для обновления патча:
# 1) cd в папку
# 2) вносим изменения
# 3) git add .
# 4) git diff --staged | pbcopy
# 5) перезаписываем старый файл патча новым
set -e
SCRIPT_DIR=` cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd `
EMCC_DIR="$(dirname $(which emcc))"
EMCC_CACHE_DIR="$EMCC_DIR/cache"
NO_GIT_CLEAN=false
GIT_CLEAN=true
# патч папки
function apply_patch {
cd "$1"
echo "Applying patch: $2"
if [ -d .git ]; then
git restore --staged .
# Разумно проводить очистку, только если нет генерируемых во время сборки файлов
# (например, allegro создаёт конфигурацию заголовков на основании окружения).
if $3 ; then
git clean -fdq
fi
git checkout -- .
else
git init > /dev/null
git add .
git commit -m init
fi
patch -s -p1 < "$2"
cd - > /dev/null
}
echo "Applying patches ..."
apply_patch "$EMCC_DIR" "$SCRIPT_DIR/emscripten.patch" $GIT_CLEAN
# Проверяем, что исходный код SDL скачан,
# иначе патчи не удастся применить.
if [ ! -d "$EMCC_CACHE_DIR/ports/sdl2" ]
then
embuilder build sdl2
fi
if [ ! -d "$EMCC_CACHE_DIR/ports/sdl2_mixer/SDL_mixer-release-2.0.4" ]
then
rm -rf "$EMCC_CACHE_DIR/ports/sdl2_mixer"
embuilder build sdl2_mixer
fi
# Вручную удаляем библиотеки из кэша Emscripten, чтобы произошла пересборка.
rm -rf "$EMCC_CACHE_LIB_DIR"/libSDL2-mt.a
rm -rf "$EMCC_CACHE_LIB_DIR"/libSDL2_mixer_gme_mid-mod-mp3-ogg.a
apply_patch "$EMCC_CACHE_DIR/ports/sdl2/SDL-4b8d69a41687e5f6f4b05f7fd9804dd9fcac0347" "$SCRIPT_DIR/sdl2.patch" $GIT_CLEAN
apply_patch "$EMCC_CACHE_DIR/ports/sdl2_mixer/SDL_mixer-release-2.0.4" "$SCRIPT_DIR/sdl2_mixer.patch" $GIT_CLEAN
apply_patch _deps/allegro5-src "$SCRIPT_DIR/allegro5.patch" $NO_GIT_CLEAN
echo "Done applying patches!"
Совершенствуем систему
Список квестов
До этого момента играбельной была лишь оригинальная Zelda. Теперь, когда заработал звук, я хотел иметь возможность играть в самодельные квесты. Для своей предыдущей работы над Quest Maker я скачал более шестисот квестов и их метаданные с PureZC.com. Каждый квест — это единый файл .qst
, и мне нужно было найти способ передавать Zelda Classic их данные. Добавить их в --preload-data
нельзя, потому что в сумме они занимают около 2 ГБ! Нет, каждый файл должен загружаться только по запросу.
Quest Maker был моей попыткой переделать Zelda Classic. Со временем я понял, что для воссоздания двадцатилетнего игрового движка потребуется ещё двадцать лет, поэтому сдался.
При создании нового файла сохранения можно выбрать файл квеста из диалогового окна выбора файла. Чтобы создать поддержку этой функции в вебе, мне нужно было заполнить пустой файл для каждого квеста, чтобы пользователь мог хотя бы выбрать его из этого диалогового окна. Чтобы это сделать, я использовал файл метаданных всего корпуса квестов, заполнив файловую систему пустыми файлами.
// Эта функция вызывается в начале настройки main().
EM_ASYNC_JS(void, em_init_fs_, (), {
// Инициализируем файловую систему файлами по 0 байт для каждого квеста.
const quests = await ZC.fetch("https://hoten.cc/quest-maker/play/quest-manifest.json");
FS.mkdir('/_quests');
function writeFakeFile(path, url) {
FS.writeFile(path, '');
window.ZC.pathToUrl[path] = 'https://hoten.cc/quest-maker/play/' + url;
}
for (let i = 0; i < quests.length; i++) {
const quest = quests[i];
if (!quest.urls.length) continue;
const url = quest.urls[0];
const path = window.ZC.createPathFromUrl(url);
writeFakeFile(path, url);
}
});
Внутриигровое окно выбора файла. Квесты хранятся в отдельных папках вида: _quests/1/OcarinaOfPower.qst
, то есть вам нужно знать, где находится нужный квест и несколько щелчков мышью, чтобы перейти к нему.
Перед тем, как Zelda Classic откроет файл, вызывается em_fetch_file_
, а данные запрашиваются и записываются в файловую систему.
EM_ASYNC_JS(void, em_fetch_file_, (const char *path), {
try {
path = UTF8ToString(path);
if (FS.stat(path).size) return;
const url = window.ZC.pathToUrl[path];
if (!url) return;
const data = await ZC.fetch(url);
FS.writeFile(path, data);
} catch (e) {
// Запрос выполнить не удалось (возможно, пользователь офлайн) или путь не существует.
console.error(`error loading ${path}`, e);
}
});
Также есть несколько квестов с внешними музыкальными файлами (mp3, ogg). Их тоже можно добавить в эту «ленивую» файловую систему:
for (const extraResourceUrl of quest.extraResources || []) {
writeFakeFile(window.ZC.createPathFromUrl(extraResourceUrl), extraResourceUrl);
}
Однако это диалоговое окно выбора файла очень неудобное. Давайте воспользуемся одной из сверхспособностей веба: URL. Я создал папку «Quest List», в которой есть ссылка Play!
:
https://hoten.cc/zc/play/?quest=731/GoGollab_1_FunnyEdition.qst
а в Zelda Classic я получал этот параметр запроса и хаком на главном экране выполнял одно из следующих действий: 1) запускал новый файл сохранения с квестом, или 2) загружал старый файл сохранения этого квеста. Это сильно упрощает переход к нужному квесту Zelda Classic.
В редакторе Zelda Classic есть функция тестирования, позволяющая редактору квестов переходить в игру на текущем редактируемом экране. Нативно это делается при помощи аргументов командной строки, а для веба у нас есть наш друг — URL. Нажмите на этот URL, и окажетесь в конце игры!
https://hoten.cc/zc/play/?quest=bs3.1/NewBS+3.1+-+1st+Quest.qst&dmap=9&screen=58
Также можно получить глубокую ссылку, чтобы открыть конкретный экран в редакторе:
https://hoten.cc/zc/create/?quest=bs3.1/NewBS+3.1+-+1st+Quest.qst&map=0&screen=55
MP3, OGG и ретро-музыка
Помните, я создавал имитацию zcmusic
, просто чтобы всё собиралось и использовалась готовая библиотека звуков? Позже я понял, что SDL_mixer поддерживает OGG и MP3, поэтому можно легко будет реализовать zcmusic
при помощи SDL_mixer. SDL_mixer и Emscripten знают, как синтезировать эти форматы аудио, поэтому мне не нужно разбираться, как самому компилировать эти звуковые библиотеки.
Стоит сказать, что у Zelda Classic есть два отдельных пути исполнения кода для музыки: один для MIDI, о котором мы уже говорили, второй для "zcmusic"
, который является просто обёрткой поверх различных звуковых библиотек для поддержки OGG, MP3 и различных форматов ретро-игр:
- gbs (GameBoy Sound)
- nsf (NES Sound Format)
- spc (SNES Sound)
- vgm (Video Game Music — сборник для различных игровых систем)
Итак, Emscripten + SDL_mixer обрабатывают всё, кроме этих ретро-форматов. Для них Zelda Classic использует библиотеку Game Music Emulator (GME). К счастью, я нашёл форк SDL_mixer под названием SDL_mixer X, интегрирующий GME в SDL. Было очень легко взять его и слить изменения в порт, который использует Emscriten. Также мне нужно было добавить GME в систему портов Emscripten, что оказалось довольно просто.
Я отправил разработчикам SDL_mixer PR для добавления GME. Если его сольют, то я добавлю в Emscripten опцию
gme
. Но пока меня вполне устраивает мой процесс с патчингом.
Для zcmusic
мне достаточно оказалось реализовать небольшую поверхность API, использующую непосредственно SDL_mixer. В нативной версии библиотеки применяются библиотеки обработки звука конкретных форматов, поэтому теперь всё гораздо проще — SDL_mixer обрабатывает всю логику, относящуюся к форматам.
Постоянные данные
По умолчанию все записываемые в файловую систему Emscripten данные хранятся только в памяти и теряются при обновлении страницы. Emscripten предоставляет простой интерфейс для монтирования папки, поддерживаемой IndexedDB, что решает проблему хранения, но по-прежнему остаётся множество других проблем:
- У игроков Zelda Classic есть файлы сохранений, которые они могут захотеть перенести в браузер
- Игроки захотят иметь доступ к этим файлам (или для создания резервных копий, или чтобы ими делиться), но браузеры не открывают IndexedDB пользователям, не обладающим техническими знаниями
- Браузеры избегают очистки данных в IndexedDB при вызове
navigator.storage.persist()
, однако утеря таких данных, как файлы сохранений (и особенно файл.qst
автора квеста), станет катастрофой, и я не хочу надеяться на то, что всё, находящееся внутри браузера, будет храниться там постоянно
Избежать всех этих проблем поможет использование реальной файловой системы. К счастью, за последний год на этом фронте произошёл большой прогресс: Filesystem Access API предоставляет возможность пользователю передать папку странице, даже позволяя странице выполнять в неё запись. При помощи Given window.showDirectoryPicker()
браузер открывает диалоговое окно выбора папки, а выбор пользователя задаётся как FileSystemDirectoryHandle
.
Единственное неудобство этого разрешения в том, что оно не сохраняется между сессиями и даже открытие диалогового окна разрешения обеспечивается пользовательским взаимодействием (что логично), поэтому при каждом последующем визите я должен показывать пользователю поток получения разрешения. По крайней мере,
FileSystemDirectoryHandle
можно кэшировать в IndexedDB, поэтому пользователю не нужно каждый раз указывать папку.
К сожалению, window.showDirectoryPicker()
реализован только в браузерах на Chromium; у Firefox нет планов по его реализации, а Safari в настоящий момент поддерживает только ограниченную часть API, называемую Origin Private Filesystem, не обеспечивающую реальных файлов на диске.
Origin Private Filesystem предоставляет уникальный идентификатор папки при помощи
navigator.storage.getDirectory()
. По спецификации эта папка необязательно должна отражаться на реальные файлы на диске, поэтому для Zelda Classic это не подходит.
Emscripten не предоставляет интерфейса для монтирования FileSystemDirectoryHandle
к своей собственной файловой системе, поэтому я написал свой. Готовый интерфейс IndexedDB очень похож на то, что мне было нужно и он хорошо обрабатывает логику синхронизации дельт в обоих направлениях, поэтому я взял его за основу своего интерфейса. Мне кажется, это было бы очень полезно для пользователей, поэтому я отправил патч Emscripten.
Хотя я рад, что могу предоставлять идеальное постоянное хранилище в Chromium, мне всё равно что-то нужно сделать с другими браузерами. IndexedDB + navigator.storage.persist()
— не самая худшая комбинация в мире, но мне нужно было решить описанные выше проблемы 1 и 2. Чтобы решить их, пользователь может:
- скачать любой отдельный файл с поддержкой IndexedDB
- выполнить одностороннюю загрузку файла или всей папки в браузер (в этом мне помог
browser-fs-access
)
Геймпады
В Zelda Classic вполне можно играть и с клавиатуры, но он поддерживает и геймпады. Веб и Emscripten тоже их поддерживают! Я надеялся, что всё сразу заработает. Для тестирования я купил контроллер Xbox, но… ничего не добился. Я заметил, что геймпад подключается только тогда, когда я активно нажимаю на кнопки в процессе загрузки страницы. Баг мог находиться где угодно: в Emscripten, в моём контроллере, в SDL, Allegro, Allegro Legacy… поэтому первым делом мне нужно было сузить границы воспроизведения проблемы.
Я написал простую SDL-программу, выводящую информацию о том, когда джойстик подключается и отключается. Скомпилировал её Emscripten, загрузил страницу и всё сработало. Так что среди подозреваемых остались только Allegro/Allegro Legacy. Я заметил разницу между запуском программы, скомпилированной для Mac и для веба: на Mac SDL распознаёт джойстик мгновенно, а в браузере распознавание происходит только первого ввода с контроллера. Так сделано намеренно — цель заключается в том, чтобы избежать потенциального вектора фингерпринтинга.
И это стало важной зацепкой — Allegro работает только тогда, когда нажимают на ввод при запуске, потому что он неправильно обрабатывает джойстики, подключенные после инициализации. При изучении SDL-интерфейса Allegro для джойстиков на глаза попалась переменная count
:
void _al_sdl_joystick_event(SDL_Event *e)
{
if (count <= 0)
return;
// ...
}
static bool sdl_init_joystick(void)
{
count = SDL_NumJoysticks(); // <<<<<<<<<<<< Всегда задаётся только один раз!
joysticks = calloc(count, sizeof * joysticks);
// ...
}
По какой-то неизвестной причине… все события джойстика игнорируются, если джойстики не подключены. От программ Allegro ожидается, что они будут вызывать al_reconfigure_joysticks
(который снова вызовет sdl_init_joystick
) при добавлении или отключении джойстика, чтобы воссоздать внутренние структуры данных, но программе не дают шанса сделать это, поскольку драйвер джойстика SDL в Allegro никогда не передаёт события SDL_JOYDEVICEADDED
, когда джойстики не подключены. Устранить ошибку было легко: удаляем ненужную защитную переменную count
, и устраняем баг использования освобождённой памяти из каждого неожиданного поведения calloc
, когда в качестве ввода передаётся 0
.
Я нашёл баг в Firefox, из-за которого мой контроллер Xbox привязывается неверно.
После всего этого подключение геймпада заработало. Стандартная раскладка кнопок тоже была правильной, но я хотел улучшить меню параметров Zelda Classic для настройки управления с геймпада: в исходном состоянии оно никак не сообщало о том, к какой кнопке привязано действие, показывая только номер кнопки (но не название). Я выяснил, что Allegro поддерживает API названий кнопок джойстика, поэтому использовал его, но без особого результата:
button button button button, button button, button ...
Проблема заключалась в том, что интерфейс джойстика SDL в Allegro не знал об API SDL для получения названия кнопки. Исправить это было просто:
Мне было любопытно узнать, как SDL способна узнавать названия кнопок, учитывая, что в Gamepad Web API не было никаких действий для получения названий. Оказалось, что SDL использует device id устройства (который Web API раскрывает), чтобы привязать известные геймпады к «стандартной» раскладке кнопок. Одну из таких баз данных можно найти здесь (но мне кажется, что в SDL список гораздо меньше). Эти конфигурации необходимы для стандартизации произвольных геймпадов под логичную раскладку (чтобы «нижняя правая кнопка» имела одинаковое значение для SDL вне зависимости от оборудования геймпада).
Поддержка мобильных устройств
Очень простое сенсорное управление
Я подумал, что было бы круто поддерживать мобильные платформы, но мне не хотелось тратить кучу времени на создание качественного сенсорного управления, поэтому результат получился довольно посредственным. Самой скучной частью было обеспечение правильной работы браузерных событий touch
. Чтобы передавать их как события в Allegro, достаточно было отобразить в JavaScript функцию C, создававшую имитацию пользовательского события Allegro:
bool has_init_fake_key_events = false;
ALLEGRO_EVENT_SOURCE fake_src;
extern "C" void create_synthetic_key_event(ALLEGRO_EVENT_TYPE type, int keycode)
{
if (!has_init_fake_key_events)
{
al_init_user_event_source(&fake_src);
a5_keyboard_queue_register_event_source(&fake_src);
has_init_fake_key_events = true;
}
ALLEGRO_EVENT event;
event.any.type = type;
event.keyboard.keycode = keycode;
al_emit_user_event(&fake_src, &event, NULL);
}
К счастью, геймпады на мобильных устройствах заработали без проблем. Вот пример того, как я играю с беспроводного контроллера Xbox на телефоне:
Качество низкое, потому что пришлось снимать на веб-камеру
PWA
Для генерации service worker я воспользовался следующей конфигурацией Workbox:
module.exports = {
runtimeCaching: [
{
urlPattern: /png|jpg|jpeg|svg|gif/,
handler: 'CacheFirst',
},
{
// Сопоставляем всё, кроме файла данных wasm, который emscripten
// кэширует в IndexedDB.
urlPattern: ({ url }) => !url.pathname.endsWith('.data'),
handler: 'NetworkFirst',
options: {
matchOptions: {
// В противном случае html-странца не будет кэшироваться (она может иметь параметры запроса).
ignoreSearch: true,
},
},
},
],
swDest: 'sw.js',
skipWaiting: true,
clientsClaim: true,
offlineGoogleAnalytics: true,
};
Это обеспечивает мне поддержку офлайна, хотя примечательно, что предварительного кэширования здесь нет: я решил отказаться от предварительного кэширования, потому что существует около 6 ГБ данных квестов, которые запрашиваются только при необходимости, поэтому пользователю хотя бы раз нужно загрузить конкретный квест онлайн, чтобы он работал офлайн. Поэтому я не видел смысла в предварительном кэшировании какой-то из частей веб-приложения.
Благодаря service worker и manifest.json веб-приложение можно установить как PWA. Я прослушиваю событие beforeinstallprompt
, чтобы отобразить собственный диалог установки:
const installEl = document.createElement('button');
installEl.textContent="Install as App";
installEl.classList.add('panel-button');
installEl.addEventListener('click', async () => {
if (!deferredPrompt) return;
const { outcome } = await deferredPrompt.prompt();
if (outcome === 'accepted') {
deferredPrompt = undefined;
installEl.textContent="Installed! Open from home screen for better experience";
setTimeout(() => installEl.remove(), 1000 * 5);
}
});
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
document.querySelector('.panel-buttons').append(installEl);
});
В Chrome при установке PWA окно просмотра переключается в полноэкранную отдельную версию приложения. К сожалению, в Android при установке такой переход отсутствует, из-за чего процесс оказывается неудобным (пользователь может решить закрыть браузер и искать только что установленное приложение или продолжить работу в текущем браузере и при последующих визитах использовать вход через приложение).
Только что выпустили Chrome 102, в котором появились
file_handlers
. Позже я точно их добавлю, чтобы обрабатывать открытие файлов.qst
из операционной системы!
Выводы
- Когда вы сталкиваетесь с багом, который кажется неотслеживаемым, прекратите пытаться отлаживать его из контекста своего приложения и попробуйте создать его минимальное воспроизведение. Так вам будет проще разбираться в проблеме, а если баг относится к зависимости, то у вас уже будет готовое воспроизведение, которое можно включить в баг-репорт.
- По возможности отправляйте баг-репорты и исправления багов! Но в то же время обеспечьте какой-нибудь способ настройки своих зависимостей, будь то жёсткие форки или патчинг системы. Нельзя позволить, чтобы баг в зависимости, который вы можете устранить, мешал развитию.
- Разбивайте на части проблемы с неизвестными решениями. Например, первую попытку портирования Zelda Classic я предпринял более года назад, и она окончилась провалом, потому что я сразу взялся за конечную задачу портирования, не уделив время изучению инструментов, из-за чего потратил силы впустую. На этот раз я избежал этого, сделав своей первой задачей полное понимание того, как портировать простейшую программу на Allegro.