Любая игра представляет собой набор файлов: изображений, звуков и.т.д. и программы, которая эти файлы воспроизводит по заданным алгоритмам. Звуки проигрываются, а изображения обрезаются в нужных пропорциях и воспроизводятся на экране в нужном порядке, как в кино, или мультипликации с той лишь разницей, что тут процессом можно управлять, используя прикладные интерфейсы — клавиатуру, мышь, джойстик, экран мобильного телефона и т.п. Управлять, не значит только переключать сцены, а управлять актерами, или даже группами актеров, влияя на сюжет или события, насколько это позволяет задумка автора.
Загрузка файлов
Для браузерной игры файлы используемые в программе, нужно сначала подгрузить в память. Для этого был написан отдельный модуль assetsm. Он позволяет добавлять файлы, ставит их в очередь и при загрузке рапортует о промежуточных результатах. Можно грузить изображения, звуки, тайлмапы и тайлсеты из редактора Tiled. А также можно расширять модуль добавляя любые другие файлы и загрузчики к ним, если нужно.
Мультиплеер
Игровой опыт по сети с другими игроками более богатый и захватывающий, чем одиночное прохождение, так что мультиплеер одна из важнейших частей любой современной игры.
Для сообщения между игроками(мультплеера), необходим отдельный веб-сервер, либо способ установки соединения напрямую. Для этого был написан еще один модуль - gameserver. Это мини-сервер с комнатами. Его также можно использовать для чатов, и как signaling для установления RTCPeerConnection.
Дирижирование и вывод на экран
Движок связывает все части воедино, позволяет подгрузить нужные файлы и задать логику для их рендера, т.е. вывода их на экран. А также имеет отправную точку для написания пользовательской логики.
Для движка была построена ООП иерархия, с применением принципов SOLID, где это возможно и использована последняя фитча js – приватные классы # и getters/setters.
Иерархия классов идет сверху вниз. т.е. класс System на самом верху регистрирует и хранит пользовательскую логику и данные, далее идет ISystem который запускает/останавливает рендер страниц и осуществляет взаимодействие между компонентами.
Пользовательская логика игры создается в унаследованных от GameStage классах, загрузка которого имеет жизненный цикл: после инициализации класса идет стадия регистрации в приложении, где можно добавить нужные модули и ресурсы(ассеты), далее - инициализации и запуска, в которых ресурсы уже будут доступны к использованию и остановка — для отчистки класса.
жизненный цикл GameStage
Классы выполняют какую-то одну функцию. Например, классы для отрисовки(DrawObjects), содержат только информацию о размерах, повороте и позиции и т.п. То, как они должны рисоваться, инкапсулируется в класс IRender и далее в дочерний класс WebGLEngine при инициализации приложения с помощью модульной системы.
Первоначально рендер делался на canvas2d, но пришлось отказаться и полностью перейти на webgl из-за невозможности в canvas2d рисовать изображения группой. Также я пробовал сделать многослойную структуру, но идея себя не оправдала. Все мержится в один слой, а если нужны эффекты наложения — можно пользоваться масками, или WebGL blend эффектами.
WebGL
WebGL – это браузерное API, надстройка над OpenGL, т.е. API другого API, внутри используется язык шейдеров(GLSL). Работать и отлаживать его в сравнении с canvas довольно сложно, но результат того стоит, на клиенте формируется бинарные данные и сразу отправляются на видеоадаптер, где по ним создается картинка. С бинарными данными также можно работать из WebAssembly, что открывает интересные перспективы для оптимизации, я делал кое-какие эксперименты в этом направлении, но это уже тема для отдельной статьи.
Схемы и примеры можно посмотреть в обучении, ссылки в конце статьи под катом.
Как работать с движком (jsge@1.1.0).
Нужно создать instance класса System передавая туда опции:
import { System, SystemSettings } from "jsge";
const app = new System(SystemSettings);
Создаем страницы будущей игры с помощью наследования от GameStage:
import { GameStage } from "jsge";
class CustomStage extends GameStage{
...
}
Описываем логику и элементы будущего окружения:
class CustomStage extends GameStage {
register() {
this.iLoader.addImage("image_key", "/images.jpg"); // добавляем изображение
}
init() {
this.player = this.draw.image(100, 200, 16, 28, "image_key"); // создаем спрайт на основе загруженного изображения
}
}
Регистрируем в системном классе:
app.registerStage("CustomStageKey", CustomStage);
Подгружаем добавленные на страницах assets и запускаем рендер:
app.preloadAllData().then(() => {
app.iSystem.startGameStage("CustomStageKey");
});
После запуска вашего instance GameStage, запускается рендер с данными хранимыми в StageData запущенной страницы.
Что сейчас реализовано.
-
рисование примитивов. Прямоугольники, многоугольники, круги, конусы. Можно менять цвет, прозрачность.
Можно создавать instance классов и добавлять на карту с помощью
GameStage.addRenderObject()
, либо создавать объекты используя factory класс, который сделает это за вас:
// полупрозрачный красный конус, радусом 120 пикселей рисуем и добавляем на экран
this.fireRange = this.draw.conus(55, 250, 120, "rgba(255, 0,0, 0.4)", Math.PI/8);
рисование текстов. Можно менять шрифт, цвет и обводку:
this.navItemBack = this.draw.text(200, 30, "Main menu", "18px sans-serif", "black");
рисование спрайтов(изображений). Для спрайтов поддерживаются границы, которые могут быть в виде многоугольника, либо круга, также можно задать индекс изображения, если картинка - тайлсет:
// рисуем картинку размером 16х16 пикселей, индексом 84 и границей в виде круга с радиусом 8 пикселей
this.player = this.draw.image(55, 250, 16, 16, "tilemap_packed", 84, {r: 8});
риcование tilemap. Tilemap, это файл генерируемый opensource редактором Tiled, содержащий информацию о карте и тайлсетах. Для увеличения производительности, маппинг обрезается по видимым границам, рисуется только то что видимо на экране в данный момент:
this.draw.tiledLayer("background", this.tilemapKey);
this.draw.tiledLayer("walls", this.tilemapKey);
почти все объекты можно перемещать и вращать.
можно считывать границы слоя, которые в дальнейшем используются для подсчета коллизий с границами спрайтов:
this.draw.tiledLayer("walls", this.tilemapKey, true);
...
if (!this.isBoundariesCollision(newCoordX, newCoordY, person)) {
person.x = newCoordX;
person.y = newCoordY;
}
Можно центрировать карту. Те если у вас tilemap больше размеров экрана, вам нужно будет смещать видимую/рисуемую область, например, при движении персонажа:
this.stageData.centerCameraPosition(x,y);
для спрайтов есть поддержка покадровой анимации, можно задавать порядок фреймов и скорость анимации:
const f = this.draw.image(this.player.x, this.player.y, 16, 16, this.fireImagesKey, 406, {r:4});
f.addAnimation("ANIMATION_FIREMOVE", [406, 407, 408, 409, 500], true, 5);
f.emit("ANIMATION_FIREMOVE");
Помимо изображений и tilemap, можно также загружать и проигрывать звуки и музыку:
register() {
this.iLoader.addAudio("audio_key", "./audio.mp3");
}
init () {
const track = page.audio.getAudio("audio_key");
track.play();
track.pause();
}
Модульная система позволяет добавять любые другие объекты и их рендер.
Помимо стандартной покадровой, в движок интегрирован рендер spine-анимаций с помощью модуля:
import SpineModuleInitialization from "jsge/modules/spine/dist/bundle.js";
class CustomPage extends GameStage {
register() {
// муодуль расширяет iLoader добавляя возможность грузить другие типы
// файлов(.skel, .atlas), а также добавляет новые объекты для рисования и их рендер
this.iSystem.installModule("spineModule", SpineModuleInitialization, "./spine/spine-assets");
this.iLoader.addSpineBinary(SPINE.SpineBinary, "./spineboy-pro.skel");
this.iLoader.addSpineAtlas(SPINE.SpineAtlas, "./spineboy-pma.atlas");
}
init () {
const spineDrawObject = this.draw.spine(-300, -300, SPINE.SpineText, SPINE.SpineAtlas);
spineDrawObject.scale(0.5);
spineDrawObject.animationState.setAnimation(0, "run", true);
}
}
Есть возможность использовать примитивные типы как маску, чтобы скрывать/показывать какие-либо области:
init() {
// создаем черную непрозрачную круг-маску
this.sightView = this.draw.circle(55, 250, 150, "rgba(0, 0, 0, 1)");
// Добавляем окружение видимое только в области круга-маски
this.draw.tiledLayer("background", this.tilemapKey, false, this.sightView);
this.draw.tiledLayer("walls", this.tilemapKey, true, this.sightView);
this.draw.tiledLayer("decs", this.tilemapKey, false, this.sightView);
const monster1 = new DrawImageObject(255, 250, 16, 16, "tilemap_packed", 108);
monster1.setMask(this.sightView);
}
Встроен интерфейс для мультиплеера, а также написана серверная часть с комнатами. Для работы, достаточно запустить серверную часть, а на клиенте поставить в опции адрес сервера
{ address: "https://your.gameserver.ru:9009" }
, настроить listeners и отправку нужных данных:
this.iSystem.iNetwork.addEventListener(CONST.EVENTS.WEBSOCKET.SERVER_CLIENT.CONNECTION_STATUS_CHANGED, this.#onConnectionStatusChanged);
this.iSystem.iNetwork.addEventListener(CONST.EVENTS.WEBSOCKET.SERVER_CLIENT.ROOMS_INFO, this.#onRoomsInfo);
this.iSystem.iNetwork.addEventListener(CONST.EVENTS.WEBSOCKET.SERVER_CLIENT.SERVER_MESSAGE, this.#onServerMessage);
this.iSystem.iNetwork.addEventListener(CONST.EVENTS.WEBSOCKET.SERVER_CLIENT.FULL, this.#onRoomIsFool); this.system.network.addEventListener(CONST.EVENTS.WEBSOCKET.SERVER_CLIENT.CREATED, this.#onCreatedNewRoom);
this.iSystem.iNetwork.addEventListener(CONST.EVENTS.WEBSOCKET.SERVER_CLIENT.JOINED, this.#onJoinedToRoom);
this.iSystem.iNetwork.sendGatherRoomsInfo();
this.iSystem.iNetwork.sendCreateOrJoinRoom(this.#selectedRoomName, {});
this.iSystem.iNetwork.sendMessage(message);
Какие игры можно делать
Движок можно использовать для разработки игр с видом сверху, либо изометрических. Для аркады с видом сбоку, нужно писать либо интегрировать расчет гравитации.
Оптимизация
В сети встречал статьи, что в популярных движках бывают сложности с рендерингом, когда карта большая. Пишут костыли, чтобы обойти эту проблему и/или дробят карту. В моем движке можно рендерить карту любого размера, при условии стандартного увеличения(scale). Для демонстрации подготовил специально большую карту tiled 800x800 ячеек по 16х16 пикселей и включил все фишки: слои маски, считывание границ слоя, детектор коллизий и протестировал работу на стареньком компе(4х-ядерном Xeon, 4 гб оп, Ubuntu, 27 дюймовый экран) и мобильном телефоне(Galaxy a52).
Долговато загружается, файл почти 4мб., но последующий рендер укладывается в 60 fps(16 мс на полный цикл последовательной отрисовки).
Результаты 4х-ядерный Xeon, 4ГБ оп, Ubuntu, 27 дюймовый экран:
карта очень примитивная, но особой роли это не играет.
Результаты на мобильном(Galaxy a52):
На мобильнике может рендерится и быстрее, но скорость ограничена ~60 fps в настройках.
Ссылки
CodePen с примера: https://codepen.io/yaalfred/pen/zYegGGb
Менеджер файлов assetsm: https://github.com/ALapinskas/assetsm
Серверная часть gameserver: https://github.com/ALapinskas/gameserver
Сам движок jsge: https://github.com/ALapinskas/jsge
Обучение с примерами и документация API: https://jsge.reslc.ru/
Редактор Tiled: https://www.mapeditor.org/
Комментарии (14)
ASGAlex
28.12.2023 22:47+2бывают сложности с рендерингом, когда карта большая. Пишут костыли, чтобы обойти эту проблему и/или дробят карту. В моем движке можно рендерить карту любого размера
Не совсем так. Размер карты сам по себе не имеет большого значения, важнее количество объектов на ней, которые приходится обсчитывать. Естественно, чтобы запихать больше объектов, нужны бОльшие карты, но это скорее следствие, а не причина.
У вас в примере на codepen я действительно увидел карту с небольшим числом объектов, с которыми современные процессоры (то есть наверное всё, что выпущено за последние 10 лет) могут справится простым перебором. А вот если объектов будет на порядки больше, то уже и самому топовому железу придётся не сладко. А уж тем более если расчёты идут на JS и в один поток (игнорируется наличие многаядер, работать будет только одно).
Можно провести простой эксперимент: сделайте такую карту, чтобы на ней было хотя бы 10 000 отдельных объектов, для которых считаются коллизии.
JordanCpp
28.12.2023 22:47Для тайловой карты сделать оптимизацию проще. Там нет коллизий в привычном смысле. Рисуем только, то что попадает на экран. Всё остальные векторное рисование, требует уже других структур.
ASGAlex
28.12.2023 22:47В плане расчёта столкновений не вижу разницы для тайловой карты и любой другой... Разве что у вас игровой процесс как в шахматах, объекты строго перемещаются по ячейкам, и сталкивать их непосредственно нет нужды.
ALapinskas Автор
28.12.2023 22:47В карте 800х800 не пустых ячеек, это 640 000 объектов для перебора, плюс для стен(я думаю их больше 10 000) считаются колизии.
JordanCpp
28.12.2023 22:47+1У меня есть ретро ПК pentium 4 1500 mhz и 2.5 Гб ОЗУ. Видеокарта geforce 6600 gt. Попробую протестировать ваш движок. Только процессор 32 битный. Поставить windows 10 или Linux с современным браузером.
Я его использую для тестирования производительности своей библиотеки, она написана на С++.
JordanCpp
28.12.2023 22:47В моем движке можно рендерить карту любого размера, при условии стандартного увеличения(scale).
Вы отсекает и не рисуете невидимые тайлы?
Вы ещё упомянули работу в 60 fps на старом железе, напишите какое это железо.
dyadyaSerezha
В таких статьях всегда хорошо с начала увидеть причину написания чего-то своего - это чисто техническое упражнение или способ преодолеть недостатки существующих движков (какие именно?). И результаты сравнения нового движка с популярными.
JordanCpp
Не понятно с чем сравнивать, не с unity же. Уверен главный недостаток современных движков в их универсальности и производительности. Особенно в производительности, но это плата за универсальность.
gtwenty
Да хотя бы с Phaser
ALapinskas Автор
Все сразу. И не растерять навыки и научится чем-нибудь, и написать, что-нибудь интересное.
Не хотелось противопоставлять свой проект другим js-движкам, внешне(с точки зрения пользователя) все очень похоже. Производительность также, наверняка, похожая. Я делал эксперименты со слоями, web assembly, ООП и приватности, про это писал.