Estudo de caso - Building Racer

Introdução

O Racer é um experimento do Chrome para dispositivos móveis baseado na Web desenvolvido pela Active Theory. Até cinco amigos podem conectar seus smartphones ou tablets para correr em todas as telas. Municiados com o conceito, o design e o protótipo do Google Creative Lab e o som do Plan8, iteramos nas criações por oito semanas antes do lançamento no I/O 2013. Agora que o jogo já está no ar há algumas semanas, tivemos a chance de responder a algumas perguntas da comunidade de desenvolvedores sobre como ele funciona. Veja abaixo uma análise dos principais recursos e respostas às perguntas mais frequentes.

A pista

Um desafio bastante óbvio que enfrentamos foi como criar um jogo para dispositivos móveis baseado na Web que funcionasse bem em uma ampla variedade de dispositivos. Os jogadores precisavam conseguir criar uma corrida com diferentes smartphones e tablets. Um jogador poderia ter um Nexus 4 e querer correr contra seu amigo que tinha um iPad. Precisávamos encontrar uma maneira de determinar um tamanho de pista comum para cada corrida. A solução tinha que envolver o uso de faixas de tamanho diferentes, dependendo das especificações de cada dispositivo incluído na corrida.

Calculando as dimensões do trilho

À medida que cada jogador entra, informações sobre o dispositivo dele são enviadas ao servidor e compartilhadas com os outros jogadores. Quando a via está sendo construída, esses dados são utilizados para calcular a altura e largura dela. Calculamos a altura descobrindo a altura da menor tela, e a largura é a largura total de todas as telas. Assim, no exemplo abaixo, a faixa teria uma largura de 1152 pixels e uma altura de 519 pixels.

A área vermelha mostra a largura e a altura totais da faixa para este exemplo.
A área vermelha mostra a largura e a altura total da faixa neste exemplo.
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;
}

Como desenhar a pista

O Paper.js é uma estrutura de script de script vetorial de código aberto executada sobre Canvas HTML5. Descobrimos que o Paper.js era a ferramenta perfeita para criar formas vetoriais para as faixas. Por isso, usamos esses recursos para renderizar as faixas SVG criadas no Adobe Illustrator em um elemento <canvas>. Para criar a faixa, a classe TrackModel anexa o código SVG ao DOM e coleta informações sobre as dimensões e o posicionamento originais que serão transmitidos para o TrackPathView, que desenha a faixa em uma tela.

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;

Depois que a faixa é desenhada, cada dispositivo encontra o deslocamento x com base na posição na ordem de alinhamento do dispositivo e posiciona a faixa adequadamente.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
O deslocamento x pode então ser usado para mostrar a parte adequada da faixa.
O deslocamento x pode ser usado para mostrar a parte adequada da faixa

Animações CSS

O Paper.js usa muito processamento de CPU para desenhar as faixas de rastreamento, e esse processo leva mais ou menos tempo em dispositivos diferentes. Para lidar com isso, precisávamos de um loader para loop até que todos os dispositivos terminassem de processar a faixa. O problema era que qualquer animação baseada em JavaScript pulava frames devido aos requisitos de CPU do Paper.js. Insira animações CSS, que são executadas em uma linha de execução de interface separada, permitindo uma animação suave do brilho no texto "BUILDING TRACK".

.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

O CSS também foi útil para efeitos de jogo. Dispositivos móveis, com energia limitada, ficam ocupados animando os carros correndo pelas pistas. Portanto, para aumentar a expectativa, usamos sprites como uma forma de implementar animações pré-renderizadas no jogo. Em um CSS Sprite, as transições aplicam uma animação baseada em etapas que muda a propriedade background-position, criando a explosão do carro.

#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;
  }
}

O problema com essa técnica é que você só pode usar folhas de sprite dispostas em uma única linha. Para fazer o loop em várias linhas, a animação deve estar encadeada por meio de várias declarações de frame-chave.

#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;
  }
}

Como renderizar os carros

Como em qualquer jogo de corrida de carros, sabíamos que era importante dar ao usuário uma sensação de aceleração e manobra. Aplicar uma quantidade diferente de tração era importante para o equilíbrio do jogo e a diversão, para que, quando o jogador entendesse a física, ele tivesse uma sensação de realização e se tornasse um corredor melhor.

Mais uma vez, chamamos o Paper.js, que vem com um extenso conjunto de utilitários para matemática. Usamos alguns dos métodos para mover o carro ao longo do caminho, além de ajustar a posição do carro e girar suavemente cada quadro.

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

Ao otimizar a renderização de carros, encontramos um ponto interessante. No iOS, o melhor desempenho foi alcançado aplicando uma transformação translate3d ao carro:

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

No Chrome para Android, o melhor desempenho foi obtido calculando os valores da matriz e aplicando uma transformação matricial:

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

Como manter os dispositivos sincronizados

A parte mais importante (e difícil) do desenvolvimento era garantir que o jogo fosse sincronizado em todos os dispositivos. Achamos que os usuários poderiam perdoar se um carro ocasionalmente pulasse alguns frames devido a uma conexão lenta, mas não seria muito divertido se o carro estivesse pulando e aparecendo em várias telas ao mesmo tempo. Resolver isso exigia muitas tentativas e erros, mas acabamos por escolher alguns truques que fizeram funcionar.

Como calcular a latência

O ponto de partida para sincronizar dispositivos é saber quanto tempo leva para as mensagens serem recebidas do redirecionamento do Compute Engine. A parte complicada é que os relógios de cada dispositivo nunca estarão totalmente sincronizados. Para contornar isso, precisávamos encontrar a diferença de tempo entre o dispositivo e o servidor.

Para encontrar o intervalo de tempo entre o dispositivo e o servidor principal, enviamos uma mensagem com o carimbo de data/hora atual do dispositivo. O servidor responderá com o carimbo de data/hora original e o do servidor. Usamos a resposta para calcular a diferença real no tempo.

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

Fazer isso uma vez não é suficiente, já que a viagem de ida e volta ao servidor nem sempre é simétrica, o que significa que pode levar mais tempo para a resposta chegar ao servidor do que para o servidor retorná-la. Para contornar isso, consultamos o servidor várias vezes, obtendo o resultado médio. Isso nos faz chegar a 10 ms da diferença real entre o dispositivo e o servidor.

Aceleração/desaceleração

Quando o Jogador 1 pressiona ou solta a tela, o evento de aceleração é enviado para o servidor. Uma vez recebido, o servidor adiciona seu carimbo de data/hora atual e, em seguida, transmite esses dados para cada outro jogador.

Quando um evento "acelerar ativado" ou "acelerar desativado" é recebido por um dispositivo, podemos usar o deslocamento do servidor (calculado acima) para descobrir quanto tempo levou para que a mensagem fosse recebida. Isso é útil, porque o jogador 1 pode receber a mensagem em 20 ms, mas o jogador 2 pode levar 50 ms para recebê-la. Isso faria com que o carro estivesse em dois lugares diferentes, porque o dispositivo 1 iniciaria a aceleração mais cedo.

Podemos usar o tempo que levou para receber o evento e convertê-lo em quadros. Em 60 fps, cada frame tem 16,67 ms, então podemos adicionar mais velocidade (aceleração) ou atrito (desaceleração) ao carro para contabilizar os frames que ele perdeu.

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();
  }
}}

No exemplo acima, se o Jogador 1 tiver o carro na tela e o tempo que levou para receber a mensagem for inferior a 75 ms, ele ajustará a velocidade do carro, acelerando para compensar a diferença. Se o dispositivo não estiver na tela ou a mensagem demorar muito, ele vai executar a função de renderização e fazer o carro pular para onde precisa estar.

Como manter os carros sincronizados

Mesmo depois de considerar a latência na aceleração, o carro ainda pode sair de sincronia e aparecer em várias telas ao mesmo tempo, especificamente ao fazer a transição de um dispositivo para outro. Para evitar isso, eventos de atualização são enviados com frequência para manter os carros na mesma posição na pista em todas as telas.

A lógica é que, a cada quatro frames, se o carro estiver visível na tela, o dispositivo vai enviar os valores para cada um dos outros dispositivos. Se o carro não estiver visível, o app vai atualizar os valores com os recebidos e avançar o carro com base no tempo necessário para receber o evento de atualização.

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();
  }
}

Conclusão

Assim que conhecemos o conceito do Racer, percebemos que poderia ser um projeto muito especial. Com rapidez, construímos um protótipo que nos deu uma breve ideia de como superar a latência e o desempenho da rede. Foi um projeto desafiador que nos manteve ocupados durante a madrugada e fins de semana longos, mas foi uma sensação ótima quando o jogo começou a tomar forma. No final das contas, estamos muito felizes com o resultado final. O conceito do Google Creative Lab ultrapassou os limites da tecnologia de navegador de um jeito divertido, e, como desenvolvedores, não poderíamos pedir mais.