2d движок для игр Javascript Game Engine (JsGE)

Любая игра представляет собой набор файлов: изображений, звуков и.т.д. и программы, которая эти файлы воспроизводит по заданным алгоритмам. Звуки проигрываются, а изображения обрезаются в нужных пропорциях и воспроизводятся на экране в нужном порядке, как в кино, или мультипликации с той лишь разницей, что тут процессом можно управлять, используя прикладные интерфейсы — клавиатуру, мышь, джойстик, экран мобильного телефона и т.п. Управлять, не значит только переключать сцены, а управлять актерами, или даже группами актеров, влияя на сюжет или события, насколько это позволяет задумка автора.

Загрузка файлов

Для браузерной игры файлы используемые в программе, нужно сначала подгрузить в память. Для этого был написан отдельный модуль assetsm. Он позволяет добавлять файлы, ставит их в очередь и при загрузке рапортует о промежуточных результатах. Можно грузить изображения, звуки, тайлмапы и тайлсеты из редактора Tiled. А также можно расширять модуль добавляя любые другие файлы и загрузчики к ним, если нужно.

Мультиплеер

Игровой опыт по сети с другими игроками более богатый и захватывающий, чем одиночное прохождение, так что мультиплеер одна из важнейших частей любой современной игры.

Для сообщения между игроками (мультплеера), необходим отдельный веб-сервер, либо способ установки соединения напрямую. Для этого был написан еще один модуль — gameserver. Это мини-сервер с комнатами. Его также можно использовать для чатов, и как signaling для установления RTCPeerConnection.

Дирижирование и вывод на экран

Движок связывает все части воедино, позволяет подгрузить нужные файлы и задать логику для их рендера, т.е. вывода их на экран. А также имеет отправную точку для написания пользовательской логики.

Для движка была построена ООП иерархия, с применением принципов SOLID, где это возможно и использована последняя фитча js — приватные классы # и getters/setters.

Иерархия классов идет сверху вниз. т.е. класс System на самом верху регистрирует и хранит пользовательскую логику и данные, далее идет ISystem который запускает/останавливает рендер страниц и осуществляет взаимодействие между компонентами.

Пользовательская логика игры создается в унаследованных от GameStage классах, загрузка которого имеет жизненный цикл: после инициализации класса идет стадия регистрации в приложении, где можно добавить нужные модули и ресурсы (ассеты), далее — инициализации и запуска, в которых ресурсы уже будут доступны к использованию и остановка — для отчистки класса.

жизненный цикл GameStage

606045834c6d82712592cca2ded7c2bc.png

Классы выполняют какую-то одну функцию. Например, классы для отрисовки (DrawObjects), содержат только информацию о размерах, повороте и позиции и т.п. То, как они должны рисоваться, инкапсулируется в класс IRender и далее в дочерний класс WebGLEngine при инициализации приложения с помощью модульной системы.

Первоначально рендер делался на canvas2d, но пришлось отказаться и полностью перейти на webgl из-за невозможности в canvas2d рисовать изображения группой. Также я пробовал сделать многослойную структуру, но идея себя не оправдала. Все мержится в один слой, а если нужны эффекты наложения — можно пользоваться масками, или WebGL blend эффектами.

WebGL

WebGL — это браузерное API, надстройка над OpenGL, т.е. API другого API, внутри используется язык шейдеров (GLSL). Работать и отлаживать его в сравнении с canvas довольно сложно, но результат того стоит, на клиенте формируется бинарные данные и сразу отправляются на видеоадаптер, где по ним создается картинка. С бинарными данными также можно работать из WebAssembly, что открывает интересные перспективы для оптимизации, я делал кое-какие эксперименты в этом направлении, но это уже тема для отдельной статьи.

Схемы и примеры можно посмотреть в обучении, ссылки в конце статьи под катом.

Как работать с движком (jsge@1.1.0).

  1. Нужно создать instance класса System передавая туда опции:

import { System, SystemSettings } from "jsge";
const app = new System(SystemSettings);
  1. Создаем страницы будущей игры с помощью наследования от GameStage:

import { GameStage } from "jsge";
class CustomStage extends GameStage{
  ...
}
  1. Описываем логику и элементы будущего окружения:

class CustomStage extends GameStage {
    register() {
        this.iLoader.addImage("image_key", "/images.jpg"); // добавляем изображение
    }
    init() {
        this.player = this.draw.image(100, 200, 16, 28, "image_key"); // создаем спрайт на основе загруженного изображения
    }
}
  1. Регистрируем в системном классе:

app.registerStage("CustomStageKey", CustomStage);
  1. Подгружаем добавленные на страницах assets и запускаем рендер:

app.preloadAllData().then(() => {
    app.iSystem.startGameStage("CustomStageKey");
});
  1. После запуска вашего 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");
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 800×800 ячеек по 16×16 пикселей и включил все фишки: слои маски, считывание границ слоя, детектор коллизий и протестировал работу на стареньком компе и мобильном телефоне.

Долговато загружается, файл почти 4 мб., но последующий рендер укладывается в 60 fps (16 мс на полный цикл последовательной отрисовки).

Результаты на стареньком 4х-ядерном Xeon (Ubuntu, 27 дюймовый экран):

jsge - Xeon x4, 4GB, 27'

jsge — Xeon x4, 4GB, 27'

карта очень примитивная, но особой роли это не играет.

Результаты на мобильном (Galaxy a52):

jsge - Galaxy A52

jsge — 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/

© Habrahabr.ru