Игра «13»: Разработка первой версии для js13k 2022

Оглавление
- Цели проекта
- Тема
- Сеть
- Графика
- Аудио - музыка и звуки
- Сжатие Кода
- 1. Сборка TypeScript
- 2. Слияние имён свойств для непересекающихся типов
- 3. `terser`: cжатие и минификация
- 4. Хеширование идентификаторов Web API
- 5. Крашинг JavaScript кода
- 6. ZIP архив
- Приёмы стилем кодирования
- Производительность
- Чтобы я делал в следующий раз
- Заключение
- Хотелки на момент конкурса
Впервые решил принять участие в конкурсе js13k - это где вам нужно создать игру для браузера, чтобы финальный архив игры занимал не более 13 килобайт, в архив должны входить все ресурсы и код игры. В моей игре в архив так же включается код Node.js сервера!
В итоге получилась игра «13» - это быстрый и кровавый 2D многопользовательский top-down шутер в режиме реального времени. Запись игры на js13k2022
Цели проекта
Личную цель я поставил себе ещё до оглашения темы конкурса. Я хотел реализовать многопользовательскую игру в режиме реального времени со схемой peer-to-peer соединения. Я никогда ещё не делал таких игр, но давно интересуюсь их реализацией.
Тема
Тема была оглашена - Смерть. Несколько дней обдумывал что-нибудь оригинальное в плане геймплея, что могло бы лучше раскрыть тему. В итоге решил - чем проще, тем лучше, и взял фокус на идею "Bullet-Hell" (это когда на экране у вас огромное количество движущихся пуль). Я понял, что если я использую эмодзи для лиц персонажей, то это будет напоминать макси психопатов-убийц (например, серия фильмов Судная Ночь).
Конечно для тактики многопользовательской игры было бы круто сделать городскую или пригородную локацию, но учитывая что в количестве ресурсов, кода и времени я сильно ограничен - я определился, что всё будет происходить на уютной лесной полянке, что может вызвать правильные ассоциации с триллерами, где убийцы выслеживают и гоняются за героями по ночному лесу. По поводу освещения я был настроен скептически, но к концу у меня хватило воли реализовать простейший "туман войны", что улучшило картинку в целом.
Вдохновлялся Nuclear Throne для механики стрельбы, и Hotline Miami с точки зрения стиля (небрежный пиксель в высоком разрешении без фильтрации), а так же струи крови и пятна на карте.
Я люблю платформеры, но принимая во внимание, что интереснее будет писать в другом жанре - я остановился на жанре top-down shooter.
Думал если повезёт - то дам чёртово название "13" и сделаю 13 персонажей и 13 видов оружия, но с оружием немного не успел.
Сеть
Использую WebRTC для быстрого обмена данными без подтверждения и без сохранения порядка отправки.
Архитектурно я использую схему Lock-step - игрок ждёт пока от всех игроков придут сообщения о вводе, и только потом мы можем проиграть этот тик. Полный детерминизм игровой логики. Для предотвращение мелких лагов я использовал схему с перекрытием отправляемых команд. Игрок назначает командам ввода время выполнения немного забегая вперёд (delayed input), несмотря на задержку отзыва на клиенте в несколько кадров мы имеем возможность гладко накапливать (буферизировать) команды на клиентах для предотвращение постоянного попадания в холодное состояние (когда не хватает ввода для симуляции тика).
В качестве Signalling server я не использовал web-sockets, а реализовал более простой обмен данными по HTTP через fetch + EventSource. Сервер держит текущие подключения и даёт клиентам возможность обмениваться простыми сообщениями друг с другом, один клиент отправляет команду адресованную другому клиенту на сервер, а сервер через серверное событие отправляет это сообщение получателю. На мой взгляд получилось наиболее компактно по сравнению с web-sockets.
Я умышленно не делал сложную обработку проблем с подключением, дропом игроков, отслеживанием наибольшего лага, процедуру кика лагающего игрока и тому подобное в целях предотвратить увеличение размера кода. В данном проекте я сфокусировался на удачном сценарии подключения.
Детерминизм
Логика на клиенте должна быть полностью детерминирована, это значит что симуляция с одними входными параметрами (включая состояние игры) должно давать только единственный и полностью определенный результат. Состояние генераторов случайных чисел, последовательность списка игровых сущностей, позиции и скорости - всё должно точно совпадать на каждом клиенте. Я добавил несколько дополнительных проверок для режима отладки, чтобы проверять корректность состояния игры между подключенными игроками. Для того чтобы было проще находить ошибки связанные с состоянием игры - я создал HTML страницу с четырьмя <iframe>
, которые содержат по одному виртуальному клиенту.
Так же стоит добавить простейшую симуляцию Задержки + Вероятность потери пакета, чтобы проверять стабильность подключения и находить проблемы связанные с таймингом (когда нужно догнать или подождать, чтобы в конечном итоге все клиенты попали в определенный разрешенный диапазон менее 8 тиков симуляции).
Графика
Для отрисовки я использовал WebGL instancing. За основу взял js13k-2d и постепенно перемешивал код. Удалил всё что связано с alpha-discard и z-buffer, добавил color-offset для эффекта попадания. Альфа компонент color-offset использую в качестве коэффициента для интерполяции режимов смешивания (blend modes) между обычным (normal) и аддитивным (additive blending). Этот приём основан на классической технике PMA (PreMultiplied Alpha) с использованием цвета умноженного на альфа-компонент (прозрачность).
Для более эффективного сжатия все необходимые GL константы перенесены в const enum
. Финальный шейдер минифицировал с помощью GLSLX.
Все игровые спрайты я сразу рисую в атлас, простые квадратики, кружки, элементы виртуального джойстика. Все emoji я рисую во временный <canvas>
и потом уменьшаю в ячейку атласа, сделано это для достижения большей пикселизации emoji. Почти каждый спрайт в атласе обрезается альфа, чтобы достичь жестких пиксельных границ объекта.
Изначально лужи крови и гильзы просто лежали на карте и копились, что сильно замедляло отрисовку кадра, поэтому в какой-то момент я превратил фон карты в FBO (Frame Buffer Object) и просто начал рисовать накопленные частицы в эту текстуру.
В новой версии игры эти следы постепенно исчезают, это сделано через подмешивание оригинальной текстуры карты в рабочую со смешиванием через прозрачность.
Освещение изначально думал сделать через сетку, но быстро понял что кода будет много, второй шейдер я добавлять не хотел, в итоге решил попробовать рисовать источники света в текстуру. Для источника света в отдельную текстуру сгенерировал мягкий шар с градиентом альфа-канала. Включил режим смешивания который вычитает альфу у приёмника.
gl.blendFunc(GL.ZERO, GL.ONE_MINUS_SRC_ALPHA);
Дальше с апскейлом и выключенной фильтрацией рисую эту текстуру поверх всего. Получился эффект "тумана войны" и визуально видны квадратики, как будто бы я это делаю через тайловую сетку.
Fog of War
Аудио - музыка и звуки
Изначально я взял то, что всегда было перед глазами - SFXR для эффектов и SoundBox для музыки. Перенеся по-быстрому код в чистый TypeScript и загрузив стандартные звуки и музыкальный трек я понял, что меня уже неплохо ограничило по размеру. А мне еще нужно было сделать симуляцию панорамного аудио: балансировать звук между левым и правым каналом, а так же вычислять громкость в зависимости от расстояния от источника звука.
И сразу я кинул силы на оптимизацию размера кода и эффективности хранения данных для звуков. Сделал пред-обработку параметров для SFXR, чтобы убрать некоторые вычисления в генерации звуков, что дало действительно хороший результат.
В итоге всё равно музыка и звуки мешали добавлять новый функционал и я нашёл ИДЕАЛЬНУЮ библиотеку ZZFX и ZZFXM, смысл музыкального проигрывателя заключается в том, что используются ZZFX семплы для инструментов, что убирало дублирование кода для генерации звука для эффектов и для музыкальных дорожек. Долгое время я тестировал с музыкальным треком Depp из примеров ZZF
К завершению проекта я планировал что у меня в запасе есть около 200 байт, которые я заберу за счет того что напишу короткий мощный трек сам, в трекере, или по крайней мере ВЫРЕЖУ паттерны из используемого трека. В итоге я тыкался в трекере, никакие музыкальные идеи я не смог реализовать. Я понимал что музыкальный плеер и сам трек будет занимать около 500 байт. Места мне уже не хватало...
Это была паника и я решил попробовать другой подход - процедурную генерацию музыки используя в те же ZZFX семплы. Взял минорную гамму и начал накидывать цикл проигрывания баса, и как всегда помощь и вдохновение пришло от любимой супруги, в какой-то момент один из запусков она узнала в бас линии трек Justice, после чего всё встало на свои места с ритмическим рисунком, я добавил сольники, бас-бочку, хеты, добавил вариативности с задержкой, добавил эквалайзер на басс-линию и автоматизировал её параметры случайным образом. В результате это заняло меньше 100 строчек кода на TypeScript!
Результатом очень доволен. Если бы было больше времени, то возможно я бы добавил отдельно 2 трека, один для заставки, а второй уже для игры.
Дальше в последний момент я добавил простейший синтез речи, который приветствует и комментирует процесс игры. Это тоже добавило разнообразия.
Сжатие Кода
1. Сборка TypeScript
Собираю TypeScript клиента и сервера напрямую используя esbuild. Здесь в сборке происходит минификация, которая будет изменять const
на let
(чего почему-то terser не делает). Так же подставляются переменные окружения (process.env.NODE_ENV
), удаляются все console.*
вызовы. Дважды проверьте tsconfig.json
: используйте esnext, выключите эмуляцию const enum
, не включайте никакие tslib
, не используйте декораторы или какие-либо другие генерирующие дополнительный код фишки.
На этом шаге я так же минифицирую index.html
используя html-minifier.
Размер: 80933 байт
2. Слияние имён свойств для непересекающихся типов
TypeScript мог бы добавить какие-то строгие правила о свойствах в типах. Давайте предположим, что любой объект будет реализован только одним конкретным типом, и не будет использовать свойства из других типов. Если мы дадим одинаковые имена этим свойствам для всех типом, то terser будет выбирать для них одинаковые сокращенные идентификаторы, что уменьшит общий словарь для минификации свойств во всём проекте. К моему удивлению в 2022 году это свойство не используется ни в одном известном мне TypeScript сборщике. Да и вообще почему-то нету идеального минификатора, который бы работал именно на уровне TypeScript (используя всю информацию о типах!)
Например, r_
и startX_
переименованы в $0_
идентификатор. pointer_
и downEvent_
переименованы в $5_
.
Размер: 78481 байт
3. terser
: cжатие и минификация
Главная опция которая даёт хороший результат это --mangle-props. Я использовал стандартное правило для определения свойств, которые можно переименовывать /._$/
. Так же нужно включить режим модуля, top-level, ECMAScript версию выставить в 2020, пометить чистые функции, включить чистые геттеры, небезопасные стрелочные функции (unsafe arrow-functions), заменить булевые литералы true
и false
в целые числа 0
и 1
, и так далее...
Размер: 32968 байт
4. Хеширование идентификаторов Web API
Мы не можем переименовать Web API методы и функции, но мы можем создать для них ссылки/ярлыки с более короткими названиями на старте игры. Как это работает?
- Получаем все свойства, которые мы будем использовать для каждого конкретного класса
- Вычисляем такой SEED, при котором сокращенные HASH значения не будут пересекаться в рамках каждого из типов. Это самый сложный этап и его реализация заслуживает отдельной статьи.
- Генерируем словарь и сортируем его с учетом кол-ва использований идентификаторов в минифицированном кодом (после terser сжатия).
- Подставляем словарь в финальную сборку и переименовываем все встреченные использования на соответствие из вычисленного словаря (это просто, потому что другие свойства уже уменьшены terser-ом).
Размер: 31457 байт
5. Крашинг JavaScript кода
Я использовал Roadroller чтобы закрашить JavaScript код и это экономит примерно 1 кб в финальном ZIP архиве. Я не могу использовать опции крашинга чтобы загрязнить глобальное окружение (pollute global scope), потому что оно начинает конфликтовать с глобальными идентификаторами объявленными в HTML странице (c для элемента canvas, b для элемента body). Размер может отливаться потому что roadroller подбирает параметры и выбирает лучшие, поэтому я использовал -O2 опцию чтобы уменьшить различия в размере между сборками (лучше понимать изменения размера при внесении изменений в исходный код).
Весь c.js
теперь можно уместить на 1 экране!
Размер: 17794 байт
6. ZIP архив
Я использовал AdvanceCOMP команду advzip
для сжания клиента и сервера с опциями --shrink-insane --iter=1000
. Так же используются короткие имена файлов c.js
, s.js
чтобы сэкономить пару байт в ZIP таблице файлов.
Размер: 13229 байт
Приёмы стилем кодирования
Современный EcmaScript и его фишки. Использовал только типы без лишней вложенности, глобальные переменные, простые функции.
- Никаких
class
/function
/constructor
ключевых слов. Толькоinterface
типы и стрелочные функции(...) => {}
Array
иObject
словари содержащие стрелочные функции вместо использованияif
иswitch
конструкций
{}
и[]
литералы, клонирование / копирование через синтаксис деконструкции:
- Опциональный доступ для свойств, методов и доступа по индексу в массивах:
arr[i]?.[j].?(args)
- Ре-экспорт часто используемых идентификаторов, например
export const {sin, cos, ...} = Math;
- Использование неопределенностей в создании массива:
[,,,,,3]
Аргументы стрелочной функции вместо объявления локальной переменной
Эту технику я продемонстрирую на функции которая генерирует текстуру фона игровой карты. Обратите внимание, я не вызываю closePath()
- это так же сделано умышлено для уменьшения количества кода. Вот изначальный вид функции и как она сжимается в игре
export const generateMapBackground = (): void => {
const map = createCanvas(BOUNDS_SIZE);
const detailsColor = ["#080", "#572"];
map.fillStyle = "#060";
map.fillRect(0, 0, BOUNDS_SIZE, BOUNDS_SIZE);
const sc = 4;
map.scale(1, 1 / sc);
for (let i = 0; i < BOUNDS_SIZE; ++i) {
map.fillStyle = detailsColor[rand(2)];
map.beginPath();
map.arc(rand(BOUNDS_SIZE), rand(BOUNDS_SIZE) * sc, 1 + rand(16), 0, PI2);
map.fill();
map.fillRect(rand(BOUNDS_SIZE), rand(BOUNDS_SIZE) * sc, 1, 4 + rand(8));
}
uploadTexture(mapTexture, map.canvas);
};
() => {
let _ = r_(1024),
$ = ["#080", "#572"];
(_.p = "#060"), _.de(0, 0, 1024, 1024), _.ee(1, 1 / 4);
for (let e = 0; e < 1024; ++e)
(_.p = $[B(2)]),
_.yo(),
_.arc(B(1024), 4 * B(1024), 1 + B(16), 0, v),
_.fill(),
_.de(B(1024), 4 * B(1024), 1, 4 + B(8));
U(f_, _.c$);
};
// BUILD: 80946
// MANGLE: 78494
// TERSER: 32999
// REHASH: 31488
// ROADROLL: 17783
// LZMA: 13217
Дальше переносим счеткик цикла и его инициализацию в аргумент функции, так мы убираем лишний let
. Дальше так же перемещаем инициализацию canvas
в аргументы. Значение 1024 так же вписывается при каждом использовании BOUNDS_SIZE
. Создадим для неё так же аргумент, чтобы в коде оно превратилось в 1-символьный идентификатор. Так же нам не нужны все 1024 итераций, поэтому заменим <= на < - экономия в 1 символ! Terser не инлайнит наш массив с цветами, поэтому делаем это руками. В результате мы сохранили 31 байт в минифицированном коде только для этой функции и это дало нам 10 байт экономии в финальном ZIP архиве!
export const generateMapBackground = (
_i: number = 0,
_size: number = BOUNDS_SIZE,
_map = createCanvas(_size),
): void => {
_map.fillStyle = "#060";
_map.fillRect(0, 0, _size, _size);
const sc = 4;
_map.scale(1, 1 / sc);
for (; _i++ < _size; ) {
_map.fillStyle = ["#080", "#572"][rand(2)];
_map.beginPath();
_map.arc(rand(_size), rand(_size) * sc, 1 + rand(16), 0, PI2);
_map.fill();
_map.fillRect(rand(_size), rand(_size) * sc, 1, 4 + rand(8));
}
uploadTexture(mapTexture, _map.canvas);
};
(_ = 0, $ = 1024, e = r_($)) => {
for (e.p = "#060", e.de(0, 0, $, $), e.ee(1, 1 / 4); _++ < $; )
(e.p = ["#080", "#572"][B(2)]),
e.yo(),
e.arc(B($), 4 * B($), 1 + B(16), 0, v),
e.fill(),
e.de(B($), 4 * B($), 1, 4 + B(8));
U(f_, e.c$);
};
// BUILD: 80933 (-13)
// MANGLE: 78481 (-13)
// TERSER: 32968 (-31)
// REHASH: 31457 (-31)
// ROADROLL: 17773 (-10)
// LZMA: 13207 (-10)
С каждой функции по 10 байт - вот и вышел килобайт!
Производительность
Чтобы уместить игру в 13kb, я выполняю множество создания массивов, клонирую объекты, делаю фильтрацию (что создаёт новые копии массивов). Несмотря на то, что это должно вызвать сильную нагрузку на сборщик мусора - игра работает на современных мобильных устройствах в 60 FPS. В последствии, я думаю, это можно будет легко оптимизировать добавив функции, которые делают все эти вещи без создания копии объекта/массива, можно будет добавить пулы объектов и использовать другие популярные грязные техники оптимизации JavaScript приложений.
Что действительно необходимо было оптимизировать в первую очередь - это найти только видимые объекты и только потом отсортировать их по глубине для отрисовки.
Так же к сожалению проверку столкновений нужно было оптимизировать ещё в начале работы, вместо полного перебора по архитипу объекта, необходимо было сразу использовать разбиение по сетке, чем максимально снизить количество пар для поиска столкновений. Мы выполняем симуляцию один и более тиков за кадр - это очень сильно оптимизировало игру уже после завершения конкурса. Оказалось, что вариант с loose-grid в размер 13kb всё таки без проблем помещается!
И всё же все приёмы сжатия кода не стоит применять там, где код чувствителен к производительности. Когда какой-то код выполняется часто, то некоторые трюки с замыканиями или изменениями объекта просто будут приводить к постоянной перекомпиляции кода V8 движком и в итоге скорее всего будут выполняться в de-opt режиме, внимательно следите за структурами данных и изменениями типов при анализе таких функций!
Чтобы я делал в следующий раз
- Использовал бы js13k Game Server для категории Server. Я просто не читал правила в самом начале конкурса, т. к. не думал, что есть отдельная категория для многопользовательских игр с сервером. Однако мой сервер получился настолько простым и миниатюрным, что я не видел смысла переводить всё на server sandbox за 12 часов до отправки проекта. Еще один момент по которому я переживал, что игра не будет работать на js13kgames.com, что не получится запостить игру со ссылки на свой heroku app
- Я бы сразу делал fixed-timestep игровой цикл (удаляем код умножения на дельту времени кадра). Я бы сразу начинал дизайн ядра и физики в целочисленных координатах (просто потому что это было бы более удобным для упаковки данных и нормализации игрового состояния).
- Я рекомендую с самого начала проекта интегрировать вопрос "Загружать Emoji?", т.к. когда у меня всё было готово, я уже в стрессе боролся с размером, т. к. перевес оказался на 3 байта. В такой момент не хочется заниматься оптимизациями в тех местах, которые уже протестированы, поэтому пришлось пересмотреть код инициализации и добавить дополнительное состояние, чтобы убрать одну условную ветку.
- Уже после отправки проекта я поменял систему проверки столкновений с обычного n^2 перебора на хеширование пространства обычной нестрогой сеткой, что оказалось очень серьёзной оптимизацией для симуляции дополнительных тиков в режиме предсказания (когда нам недостаточно событий от других игроков). В оригинальном проекте к сожалению сильное забегание вперёд может приводить к низкому FPS.
- Писал бы черновики для этого отчёта по мере реализации проекта.
Заключение
Участие в данном конкурсе было захватывающим и достаточно сложным. Первые две недели я не ожидал, что у меня вообще хоть что-то получится сделать. Разработка игрового мультиплеера была самой сложной задачей в проекте игры 13, но вместе с тем я получил исключительный опыт разработки игр!
Хотелки на момент конкурса
- Опыт и прокачка в процессе игры, смысле копить опыт и случайным образом получать улучшения характеристик (
сделано после конкурса в виде траты монет )
- Механика патронов (
сделано после конкурса! )
- Механика перезарядки (
сделано после конкурса! )
- Второй слот для оружия - менять (
сделано после конкурса! )
- Гранатомёт, гранаты, взрывы бочек
- Лазерный пулемёт
- Ножницы (бумеганг или множественное отражение)
- Генерация интересной тайловой карты для большей тактики (
сделано после конкурса! )
- Игровые комнаты: подключится в случайную комнату и создание приватных комнат (
сделано после конкурса! )
- Поддержка Gamepad