C贸mo desarroll茅 un juego m贸vil para Android usando React.js y lo puse en Google Play Store

En este art铆culo, consideraremos todas las etapas de desarrollo: desde la concepci贸n de la idea hasta la implementaci贸n de partes individuales de la aplicaci贸n, incluyendo selectivamente, se proporcionar谩n algunas piezas de c贸digo personalizadas.





Este art铆culo puede ser 煤til para aquellos que est谩n pensando o comenzando a desarrollar juegos o aplicaciones m贸viles.





Captura de pantalla del juego terminado
Captura de pantalla del juego terminado

- , .





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





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





, . " " , JavaScript React, , .





. , . -, .





La posici贸n de inicio del jugador, donde se puede observar el mundo isom茅trico, as铆 como las posibles direcciones de movimiento.
, ,

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





Juega antes de buscar elementos gr谩ficos

- , . , :





Sprite de helic贸ptero

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



Ventana de configuraci贸n del juego

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.





PD Un agradecimiento especial al m煤sico Anton Zvarych por proporcionar m煤sica de fondo.








All Articles