В данной статье рассмотрим все этапы разработки: от зарождения идеи до имплементации отдельных частей приложения, в том числе выборочно будут предоставлены некоторые кастомные куски кода.
Данная статья может быть полезна тем, кто только задумывается или начинает разрабатывать игры или мобильные приложения.
Порядок заголовков не является структурированным по каким-либо параметрам, кроме хронологической последовательности.
Предыстория
Добрый день, уважаемые читатели. Конечно же мы с Вами не знакомы, однако если бы были, то Вы бы знали, что ещё с раннего детства после приобретения отцом персонального компьютера у меня была мечта стать разработчиком игр, что в дошкольном возрасте выражалось в "создании" платформеров мелом на асфальте для ребят во дворе, а в школьном - отдельными играми в тетрадках, в том числе и во время уроков.
Разработчиком игр (пока что) так я и не стал, но стал веб-разработчиком, имея на данный момент 8+ лет коммерческого опыта. Однажды на глаза попалась весьма интересная библиотека-фреймворк для создания пошаговых игр, документация которой была незамедлительно изучена. Предлагалось создавать такие игры, как крестики-нолики или шахматы, однако это показалось скучным и мне захотелось пойти дальше - сделать нечто посложнее и похожее на то, во что я играл ещё в детстве. Вероятно, старички заметят некоторое сходство финального результата с той игрой, на которую делался акцент.
Построение и прорисовка мира
Сперва было принято решение создать изометрический мир, который мог бы поддерживать бесконечную прокрутку по горизонтали. После тщательного изучения статей на тему "Как создавать изометрические миры" и им подобных, а также просмотра библиотек для JavaScript и React, которые якобы должны помогать выполнять данную задачу, дело перешло к практике.
Времени на это было потрачено немало и готовых подходящих решений так и не было найдено. Ну что ж, напишем своё. По сути весь наш мир это набор квадратиков-тайлов, которые находятся рядом друг с другом и на соседний квадратик можно перейти в восьми направлениях.
Сперва отрисуем ячейки мира построчно. Пускай они будут размером 64x64 пикселя. Далее развернём наш контейнер таким образом, чтобы он выглядел изометрично:
.rotate {
transform: rotateX(60deg) rotateZ(45deg);
transform-origin: left top;
}
При имплементации данного подхода к отрисовке можно наблюдать, что необходимые нам "строки" мира на самом деле идут не прямо, а зигзагом, так как мы развернули нашу карту. Таким образом, каждую из ячеек необходимо позиционировать абсолютно с учетом текущего индекса строки, а также индекса колонки:
const cellOffsets = {};
export function getCellOffset(n) {
if (n === 0) {
return 0;
}
if (cellOffsets[n]) {
return cellOffsets[n];
}
const result = 64 * (Math.floor(n / 2));
cellOffsets[n] = result;
return result;
}
Использование:
import { getCellOffset } from 'libs/civilizations/helpers';
// ...
const offset = getCellOffset(columnIndex);
// ...
style={{
transform: `translateX(${(64 * rowIndex) + (64 * columnIndex) - offset}px) translateY(${(64 * rowIndex) - offset}px)`,
}}
Для увеличения производительности нам необходимо перерисовывать только те ячейки карты, которые сейчас являются видимыми на экране. Для этого был использован компонент FixedSizeGrid
из модифицированной версии библиотеки react-window
с учетом нашего поворота и расположений ячеек, код которого здесь приводить не буду. Из того, что не получилось - это сделать бесконечную прокрутку мира. После изучений исходного различных библиотек для бесконечного скролла / слайдеров и тп. подходящего решения найдено не было. Что ж, значит наш мир будет с границами по всем бокам.
Графика
Для отображения поворотов наших юнитов, а также анимации атаки, используются png-спрайты. Поиск графических элементов занял очень большой промежуток времени, было очень сложно найти хотя бы какие-то картинки возможных юнитов для нашей игры. Изначально ещё до поиска игра выглядела вот так:
Вообще сам я не дизайнер и попросить кого-либо нарисовать тоже возможности нет, потому каждый кадр спрайта пришлось ручками размещать в необходимую позицию. Для примера, финальный спрайт вертолёта выглядит вот так:
Локализация
Игра поддерживает 4 языка и, если честно, мне непонятно, зачем в несложных приложениях разработчики подключают массивные библиотеки типа react-i18next
. Давайте напишем похожее кастомное решение, которое уместится в чуть более чем 100 строк с учетом красивой разметки кода, а также будет поддерживать определение языка девайса пользователя, переключение языков в реальном времени и сохранение последнего выбора пользователя. Здесь используется redux
, однако данный код можно адаптировать и под другие реактивные хранилища. Да, здесь нет некоторых фишек больших библиотек типа поддержки переменных в строках, однако в таком проекте нам это и не нужно. И да, эту библиотеку можно использовать как легковесную замену react-i18next
(или подобным) в уже существующем проекте.
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import get from 'lodash/get';
import set from 'lodash/set';
import size from 'lodash/size';
import { emptyObj, EN, LANG, PROPS, langs } from 'defaults';
import { getLang } from 'reducers/global/selectors';
import en from './en';
export function getDetectedLang() {
if (!global.navigator) {
return EN;
}
let detected;
if (size(navigator.languages)) {
detected = navigator.languages[0];
} else {
detected = navigator.language;
}
if (detected) {
detected = detected.substring(0, 2);
if (langs.indexOf(detected) !== -1) {
return detected;
}
}
return EN;
}
const options = {
lang: global.localStorage ?
(localStorage.getItem(LANG) || getDetectedLang()) :
getDetectedLang(),
};
const { lang: currentLang } = options;
const translations = {
en,
};
if (!translations[currentLang]) {
try {
translations[currentLang] = require(`./${currentLang}`).default;
} catch (err) {} // eslint-disable-line
}
export function setLang(lang = EN) {
if (langs.indexOf(lang) === -1) {
return;
}
if (global.localStorage) {
localStorage.setItem(LANG, lang);
}
set(options, [LANG], lang);
if (!translations[lang]) {
try {
translations[lang] = require(`./${lang}`).default;
} catch (err) {} // eslint-disable-line
}
}
const mapStateToProps = (state) => {
return {
lang: getLang(state),
};
};
export function t(path) {
const { lang = get(options, [LANG], EN) } = get(this, [PROPS], emptyObj);
if (!translations[lang]) {
try {
translations[lang] = require(`./${lang}`).default;
} catch (err) {} // eslint-disable-line
}
return get(translations[lang], path) || get(translations[EN], path, path);
}
function i18n(Comp) {
class I18N extends Component {
static propTypes = {
lang: PropTypes.string,
}
static defaultProps = {
lang: EN,
}
constructor(props) {
super(props);
this.t = t.bind(this);
}
componentWillUnmount() {
this.unmounted = true;
}
render() {
return (
<Comp
{...this.props}
t={this.t}
/>
);
}
}
return connect(mapStateToProps)(I18N);
}
export default i18n;
Использование:
import i18n from 'libs/i18n';
// ...
static propTypes = {
t: PropTypes.func,
}
// ...
const { t } = this.props;
// ...
{t(['path', 'to', 'key'])}
// ...или тоже самое, но слегка медленнее
{t('path.to.key')}
// ...
export default i18n(Comp);
Мультиплеер
Игра поддерживает мультиплеер в реальном времени для устройств с Android 9 или выше (возможно, будет работать и на 8-м, однако данное предположение не проверялось) с рейтингом и таблицей лидеров.
Сам движок не поддерживает ходы в реальном времени, потому многопользовательский режим построен таким образом, что все события происходят в один и тот же ход, в это же время существуют кулдауны на определенные действия, которые реализованы через requestAnimationFrame
. На Android 7 и ранее такой подход почему-то просто-напросто не работает.
Кастомный код библиотеки, которая регистрирует requestAnimationFrame
, для интересующихся (не забывайте также, что после регистрации колбека его нужно и отрегистрировать, что обычно происходит по завершении колбека или на анмаут компонента):
import isFunction from 'lodash/isFunction';
let lastTime = 0;
const vendors = ['ms', 'moz', 'webkit', 'o'];
for (let x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame = window[`${vendors[x]}RequestAnimationFrame`];
window.cancelAnimationFrame = window[`${vendors[x]}CancelAnimationFrame`] || window[`${vendors[x]}CancelRequestAnimationFrame`];
}
if (!window.requestAnimationFrame) {
window.requestAnimationFrame = (callback) => {
const currTime = new Date().getTime();
const timeToCall = Math.max(0, 16 - (currTime - lastTime));
const id = window.setTimeout(() => { callback(currTime + timeToCall); },
timeToCall);
lastTime = currTime + timeToCall;
return id;
};
}
if (!window.cancelAnimationFrame) {
window.cancelAnimationFrame = (id) => {
clearTimeout(id);
};
}
let lastFrame = null;
let raf = null;
const callbacks = [];
const loop = (now) => {
raf = requestAnimationFrame(loop);
const deltaT = now - lastFrame;
// do not render frame when deltaT is too high
if (deltaT < 160) {
let callbacksLength = callbacks.length;
while (callbacksLength-- > 0) {
callbacks[callbacksLength](now);
}
}
lastFrame = now;
};
export function registerRafCallback(callback) {
if (!isFunction(callback)) {
return;
}
const index = callbacks.indexOf(callback);
// remove already existing the same callback
if (index !== -1) {
callbacks.splice(index, 1);
}
callbacks.push(callback);
if (!raf) {
raf = requestAnimationFrame(loop);
}
}
export function unregisterRafCallback(callback) {
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
if (callbacks.length === 0 && raf) {
cancelAnimationFrame(raf);
raf = null;
}
}
Использование:
import { registerRafCallback, unregisterRafCallback } from 'client/libs/raf';
// ...
registerRafCallback(this.cooldown);
// ...
componentWillUnmount() {
unregisterRafCallback(this.cooldown);
}
Стандартная имплементация Lobby
из библиотеки движка мне не подходила, так как она открывала ещё одно новое websocket-подключение на каждый инстанс игры, но мне также нужно было передавать данные пользователя и таблицу лидеров по своему уже существующему websocket-подключению, потому, чтобы не плодить подключения, здесь снова было использовано собственное решение на основе библиотеки primus
. На стороне клиента подключение хендлится сбилдженной библиотекой от примуса, которое также выложил на npm с именем primus-client
. Вы можете сами сбилдить себе подобную клиентскую библиотеку для определенной версии примуса через функцию save
на стороне сервера.
Видео геймплея многопользовательского режима можно наблюдать ниже:
Звук и музыка
В игре присутствует несколько звуков - старта игры, атаки и победы. Для проигрывания звука - также кастомная библиотека (для музыки - схожий подход):
import { SOUND_VOLUME } from 'defaults';
const Sound = {
audio: null,
volume: localStorage.getItem(SOUND_VOLUME) || 0.8,
play(path) {
const audio = new Audio(path);
audio.volume = Sound.volume;
if (Sound.audio) {
Sound.audio.pause();
}
audio.play();
Sound.audio = audio;
},
};
export function getVolume() {
return Sound.volume;
}
export function setVolume(volume) {
Sound.volume = volume;
localStorage.setItem(SOUND_VOLUME, volume);
}
export default Sound;
Использование:
import Sound from 'client/libs/sound';
// ...
Sound.play('/mp3/win.mp3');
Сборка проекта
Сборка web-части осуществляется вебпаком. Однако тут нужно учитывать особенности путей к файлам, ведь в процессе разработке на локалхосте или на сервере в продакшене они являются относительными корня домена, а для приложения в Cordova наши файлы будут размещены по протоколу file://
и потому после сборки нам необходимо провести некоторые преобразования, а именно:
const replace = require('replace-in-file');
const path = require('path');
const options = {
files: [
path.resolve(__dirname, './app/*.css'),
path.resolve(__dirname, './app/*.js'),
path.resolve(__dirname, './app/index.html'),
],
from: [/url\(\/img/g, /href="\//g, /src="\//g, /"\/mp3/g],
to: ['url(./img', 'href="./', 'src="./', '"./mp3'],
};
replace(options)
.then((results) => {
console.log('Replacement results:', results);
})
.catch((error) => {
console.error('Error occurred:', error);
});
Итоги
Приложение разрабатывалось в течении года, находится в Google Play Store с середины сентября, а значит уже прошло три месяца. Общее количество установок - 46, из которых ещё непонятно, сколько там на самом деле настоящих людей. Если коротко, то это провал. Однако был приобретен первичный опыт как разработки игр, так и мобильных приложений.
Из того, что было задумано, но не получилось:
Более сложный геймплей
Бесконечная прокрутка карты по горизонтали
Продвинутый ИИ компьютера
Поддержка мультиплеера на всех устройствах
Дальнейшие планы
Сейчас понятно, что подобные игры мало кому интересны, так что в прогрессе изучение Unity, и возможно через некоторое время появится ещё одна игра в жанре tactical rts.
Можно потыкать?
Можно. Для интересующихся - ссылка на приложение на Google Play Store.
P.S. Отдельное спасибо музыканту Anton Zvarych за предоставленную фоновую музыку.
IvanTheCrazy
Колоссальная работа. Вы молодец. Но у меня есть вопрос — а что с производительностью? На каком-нибудь ширпотребном xiaomi за условные 15-20тыс рублей? Всегда этот момент смущал в гибридных играх. Сам занимался разработкой гибридных приложений (не тяжелых), там все хорошо. А здесь как?
HydraOrc Автор
У меня на моём Meizu M2 Note игра против компьютера летала. С производительностью специально заморачивался через shouldComponentUpdate в каждом из компонентов, ререндерил только на действительно те изменения, которые нужно отобразить.
Возможно будет работать немного медленнее если всё-таки заморочить голову и дописать логику для ИИ, но пока что проблем нет.
Да Вы и сами можете на каком-нибудь старичке протестировать и отписать о результатах.
IvanTheCrazy
Да блин нет старичка. Пробовал на galaxy note 10+, но на нем вообще мало что тормозит, потому и спросил. Кстати не пробовали capacitor вместо cordova?
HydraOrc Автор
Не пробовал. Использовал инструменты, с которыми в прошлом уже был какой-либо опыт
tm1218
это шикарные по производительности характеристики, на таком всё летать будет.