Caso de éxito: Building Racer

Introducción

Racer es un experimento de Chrome para dispositivos móviles basado en la Web y desarrollado por Active Theory. Hasta 5 amigos pueden conectar sus teléfonos o tablets para correr por todas las pantallas. Con el concepto, el diseño y el prototipo de Google Creative Lab y el sonido de Plan8, iteramos las compilaciones durante 8 semanas antes del lanzamiento de I/O 2013. Ahora que el juego se publicó hace algunas semanas, tuvimos la oportunidad de responder algunas preguntas de la comunidad de desarrolladores sobre cómo funciona. A continuación, se presenta un desglose de las funciones clave y respuestas a las preguntas más frecuentes.

La pista

Un desafío bastante obvio que enfrentamos era cómo crear un juego para dispositivos móviles basado en la Web que funcionara bien en una amplia variedad de dispositivos. Los jugadores debían poder construir una carrera con distintos teléfonos y tablets. Un jugador podría tener un Nexus 4 y querer competir contra su amigo que tenía un iPad. Necesitábamos encontrar una forma de determinar un tamaño de pista común para cada carrera. La solución debía implicar el uso de pistas de diferentes tamaños en función de las especificaciones de cada dispositivo incluido en la carrera.

Cómo calcular las dimensiones de las pistas

A medida que cada jugador se une, se envía información sobre su dispositivo al servidor y se comparte con otros jugadores. Durante la construcción del recorrido, estos datos se utilizan para calcular su altura y ancho. Calculamos la altura buscando la altura de la pantalla más pequeña, y el ancho es el ancho total de todas las pantallas. Por lo tanto, en el siguiente ejemplo, la pista tendría un ancho de 1,152 píxeles y una altura de 519 píxeles.

El área roja muestra el ancho y la altura totales de la pista en este ejemplo.
El área roja muestra el ancho y la altura totales de la pista en este ejemplo.
this.getDimensions = function () {
  var response = {};
  response.width = 0;
  response.height = _gamePlayers[0].scrn.h; // First screen height
  response.screens = [];
  
  for (var i = 0; i < _gamePlayers.length; i++) {
    var player = _gamePlayers[i];
    response.width += player.scrn.w;

    if (player.scrn.h < response.height) {
      // Find the smallest screen height
      response.height = player.scrn.h;
    }
      
    response.screens.push(player.scrn);
  }
  
  return response;
}

Cómo dibujar la pista

Paper.js es un marco de trabajo de secuencias de comandos de gráficos vectoriales de código abierto que se ejecuta sobre el lienzo de HTML5. Descubrimos que Paper.js era la herramienta perfecta para crear formas vectoriales de los segmentos, por lo que usamos sus capacidades para renderizar los segmentos SVG creados en Adobe Illustrator en un elemento <canvas>. Para crear la pista, la clase TrackModel agrega el código SVG al DOM y recopila información sobre las dimensiones y el posicionamiento originales que se pasarán al TrackPathView, que dibujará el seguimiento en un lienzo.

paper.install(window);
_paper = new paper.PaperScope();
_paper.setup('track_canvas');
                    
var svg = document.getElementById('track');
var layer = new _paper.Layer();

_path = layer.importSvg(svg).firstChild.firstChild;
_path.strokeColor = '#14a8df';
_path.strokeWidth = 2;

Una vez dibujado el recorrido, cada dispositivo encuentra su desplazamiento x en función de su posición en el orden de alineación de dispositivos y posiciona el recorrido en consecuencia.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
El desplazamiento x se puede utilizar para mostrar la parte adecuada de la pista.
El desplazamiento x se puede usar para mostrar la parte adecuada de la pista.

Animaciones CSS

Paper.js utiliza una gran cantidad de procesamiento de CPU para trazar los carriles y este proceso tardará más o menos tiempo en diferentes dispositivos. Para controlar esto, necesitábamos un cargador que se ejecutara en bucle hasta que todos los dispositivos terminen de procesar el segmento. El problema era que cualquier animación basada en JavaScript omitía fotogramas debido a los requisitos de CPU de Paper.js. Ingresa las animaciones de CSS, que se ejecutan en un subproceso de IU independiente, lo que nos permite animar suavemente el brillo en el texto “CREACIÓN DE PISTA”.

.glow {
  width: 290px;
  height: 290px;
  background: url('img/track-glow.png') 0 0 no-repeat;
  background-size: 100%;
  top: 0;
  left: -290px;
  z-index: 1;
  -webkit-animation: wipe 1.3s linear 0s infinite;
}

@-webkit-keyframes wipe {
  0% {
    -webkit-transform: translate(-300px, 0);
  }

  25% {
    -webkit-transform: translate(-300px, 0);
  }

  75% {
    -webkit-transform: translate(920px, 0);
  }

  100% {
    -webkit-transform: translate(920px, 0);
  }
}
}

Sprites CSS

CSS también fue útil para los efectos dentro del juego. Los dispositivos móviles, con su potencia limitada, siguen ocupados animando los autos que circulan por las vías. Para generar entusiasmo adicional, usamos objetos como una forma de implementar animaciones renderizadas previamente en el juego. En un objeto CSS, las transiciones aplican una animación basada en pasos que cambia la propiedad background-position y crea la explosión del automóvil.

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation: play-sprite 0.33s linear 0s steps(9) infinite;
}

@-webkit-keyframes play-sprite {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -900px 0;
  }
}

El problema de esta técnica es que solo puedes usar hojas de objeto dispuestas en una sola fila. Para repetir indefinidamente varias filas, la animación debe encadenarse a través de varias declaraciones de fotogramas clave.

#sprite {
  height: 100px; 
  width: 100px;
  background: url('sprite.jpg') 0 0 no-repeat;
  -webkit-animation-name: row1, row2, row3;
  -webkit-animation-duration: 0.2s;
  -webkit-animation-delay: 0s, 0.2s, 0.4s;
  -webkit-animation-timing-function: steps(5), steps(5), steps(5);
  -webkit-animation-fill-mode: forwards;
}

@-webkit-keyframes row1 {
  0% {
    background-position: 0 0;
  }

  100% {
    background-position: -500px 0;
  }
}

@-webkit-keyframes row2 {
  0% {
    background-position: 0 -100px;
  }

  100% {
    background-position: -500px -100px;
  }
}

@-webkit-keyframes row3 {
  0% {
    background-position: 0 -200px;
  }

  100% {
    background-position: -500px -200px;
  }
}

Cómo renderizar los vehículos

Al igual que con cualquier juego de carreras de autos, sabíamos que era importante brindarle al usuario una sensación de aceleración y manejo. Aplicar una cantidad diferente de tracción era importante para el equilibrio del juego y el factor divertido, de modo que una vez que un jugador se familiarizara con la física, tendría una sensación de logro y se convirtiera en un mejor piloto de carreras.

Una vez más, llamamos a Paper.js, que viene con un amplio conjunto de utilidades matemáticas. Utilizamos algunos de sus métodos para mover el automóvil a lo largo de la ruta y, al mismo tiempo, ajustar la posición y la rotación del vehículo suavemente en cada fotograma.

var trackOffset = _path.length - (_elapsed % _path.length);
var trackPoint = _path.getPointAt(trackOffset);
var trackAngle = _path.getTangentAt(trackOffset).angle;

// Apply the throttle
_velocity.length += _throttle;

if (!_throttle) {
  // Slow down since the throttle is off
  _velocity.length *= FRICTION;
}

if (_velocity.length > MAXVELOCITY) {
  _velocity.length = MAXVELOCITY;
}

_velocity.angle = trackAngle;
trackOffset -= _velocity.length;
_elapsed += _velocity.length;

// Find if a lap has been completed
if (trackOffset < 0) {
  while (trackOffset < 0) trackOffset += _path.length;

  trackPoint = _path.getPointAt(trackOffset);
  console.log('LAP COMPLETE!');
}

if (_velocity.length > 0.1) {
  // Render the car if there is actually velocity
  renderCar(trackPoint);
}

Mientras optimizamos la renderización de automóviles, encontramos un punto interesante. En iOS, se logró el mejor rendimiento aplicando una transformación translate3d al vehículo:

_car.style.webkitTransform = 'translate3d('+_position.x+'px, '+_position.y+'px, 0px)rotate('+_rotation+'deg)';

En Chrome para Android, se logró el mejor rendimiento calculando los valores de las matrices y aplicando una transformación de matriz:

var rad = _rotation.rotation * (Math.PI * 2 / 360);
var cos = Math.cos(rad);
var sin = Math.sin(rad);
var a = parseFloat(cos).toFixed(8);
var b = parseFloat(sin).toFixed(8);
var c = parseFloat(-sin).toFixed(8);
var d = a;
_car.style.webkitTransform = 'matrix(' + a + ', ' + b + ', ' + c + ', ' + d + ', ' + _position.x + ', ' + _position.y + ')';

Mantener los dispositivos sincronizados

La parte más importante (y difícil) del desarrollo fue asegurarse de que el juego se sincronizara en todos los dispositivos. Pensamos que los usuarios podían perdonar el hecho de que un automóvil a veces omitiera algunos fotogramas debido a una conexión lenta, pero no sería muy divertido si un automóvil salta de un lado a otro y aparece en varias pantallas a la vez. Resolver este problema requirió de mucho ensayo y error, pero finalmente nos decidimos por algunos trucos que hicieron que funcionara.

Calcula la latencia

El punto de partida para sincronizar dispositivos es saber cuánto tarda en recibirse los mensajes de la retransmisión de Compute Engine. Lo complicado es que los relojes de cada dispositivo nunca estarán completamente sincronizados. Para sortear esto, necesitábamos encontrar la diferencia de tiempo entre el dispositivo y el servidor.

Para encontrar la compensación horaria entre el dispositivo y el servidor principal, enviamos un mensaje con la marca de tiempo actual del dispositivo. Luego, el servidor responderá con la marca de tiempo original junto con la marca de tiempo del servidor. Usamos la respuesta para calcular la diferencia real en el tiempo.

var currentTime = Date.now();
var latency = Math.round((currentTime - e.time) * .5);
var serverTime = e.serverTime;
currentTime -= latency;
var difference = currentTime - serverTime;

Hacer esto una vez no es suficiente, ya que el recorrido de ida y vuelta al servidor no siempre es simétrico, lo que significa que puede tardar más tiempo que la respuesta en llegar al servidor que el servidor en devolverla. Para solucionar este problema, sondeamos el servidor varias veces y tomamos la mediana del resultado. Esto nos pone a menos de 10 ms de la diferencia real entre el dispositivo y el servidor.

Aceleración/desaceleración

Cuando el Jugador 1 presiona o suelta la pantalla, se envía el evento de aceleración al servidor. Una vez recibidas, el servidor agrega su marca de tiempo actual y, luego, pasa esos datos a todos los demás jugadores.

Cuando un dispositivo recibe un evento "acelerar encendido" o "acelerar desactivado", podemos usar la compensación del servidor (calculada más arriba) para averiguar cuánto tardó en recibir el mensaje. Esto es útil, ya que el Reproductor 1 podría recibir el mensaje en 20 ms, pero el Reproductor 2 podría tardar 50 ms en recibirlo. De esta manera, el automóvil estaría en dos lugares diferentes porque el dispositivo 1 iniciaría la aceleración antes.

Podemos tomarnos el tiempo necesario para recibir el evento y convertirlo en marcos. A 60 FPS, cada fotograma es de 16.67 ms, por lo que podemos agregar más velocidad (aceleración) o fricción (desaceleración) al automóvil para tener en cuenta los fotogramas que omitió.

var frames = time / 16.67;
var onScreen = this.isOnScreen() && time < 75;

for (var i = 0; i < frames; i++) {
  if (onScreen) {
    _velocity.length += _throttle * Math.round(frames * .215);
  } else {
    _this.render();
  }
}}

En el ejemplo anterior, si el Jugador 1 tiene el automóvil en la pantalla y el tiempo que tardó en recibir el mensaje es inferior a 75 ms, ajustará la velocidad del automóvil y lo acelera para compensar la diferencia. Si el dispositivo no está en pantalla o el mensaje tardó demasiado, ejecutará la función de renderización y hará que el vehículo salte al lugar correcto.

Mantener los vehículos sincronizados

Incluso si se tiene en cuenta la latencia en la aceleración, el vehículo podría desincronizarse y aparecer en varias pantallas a la vez, en especial cuando se realiza la transición de un dispositivo a otro. Para evitar esto, los eventos de actualización se envían con frecuencia para mantener los vehículos en la misma posición en el recorrido en todas las pantallas.

La lógica es que, cada 4 fotogramas, si el automóvil está visible en la pantalla, ese dispositivo envía sus valores a cada uno de los otros dispositivos. Si el automóvil no está visible, la app actualiza los valores con los recibidos y, luego, avanza en función del tiempo que tardó en obtener el evento de actualización.

this.getValues = function () {
  _values.p = _position.clone();
  _values.r = _rotation;
  _values.e = _elapsed;
  _values.v = _velocity.length;
  _values.pos = _this.position;

  return _values;
}

this.setValues = function (val, time) {
  _position.x = val.p.x;
  _position.y = val.p.y;
  _rotation = val.r;
  _elapsed = val.e;
  _velocity.length = val.v;

  var frames = time / 16.67;

  for (var i = 0; i < frames; i++) {
    _this.render();
  }
}

Conclusión

En cuanto escuchamos el concepto del corredor, nos dimos cuenta de que tenía el potencial para ser un proyecto muy especial. Construimos rápidamente un prototipo que nos dio una idea aproximada de cómo superar la latencia y el rendimiento de la red. Fue un proyecto desafiante que nos mantuvo ocupados durante las noches largas y los fines de semana largos, pero fue una gran sensación cuando el juego comenzó a tomar forma. En definitiva, estamos muy contentos con el resultado final. El concepto de Google Creative Lab expandió los límites de la tecnología de los navegadores de una manera divertida y, como los desarrolladores, no podíamos pedir más información.