How I developed an Android mobile game using React.js and put it on the Google Play Store

In this article, we will consider all stages of development: from the conception of the idea to the implementation of individual parts of the application, including selectively some custom pieces of code will be provided.





This article can be useful for those who are just thinking or starting to develop games or mobile applications.





Screenshot of the finished game
Screenshot of the finished game

- , .





, . , , , , "" , - , .





( ) , -, 8+ . - , . , - , - , . , , .





, . " " , JavaScript React, , .





. , . -, .





The starting position of the player, where you can observe the isometric world, as well as possible directions of movement
, ,

. 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-. , - . :





Play before searching for graphic elements

- , . , :





Helicopter Sprite

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');
      
      



Game settings window

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.





PS Special thanks to musician Anton Zvarych for providing background music.








All Articles