Case study - Costruire un pilota

Active Theory
Active Theory

Introduzione

Racer è un esperimento Chrome per dispositivi mobili basato sul web sviluppato da Active Theory. Fino a 5 amici possono collegare il proprio smartphone o tablet per gareggiare contro ogni schermo. Grazie al concept, al design e al prototipo di Google Creative Lab e al suono di Plan8, abbiamo eseguito l'iterazione delle build per 8 settimane prima del lancio all'I/O 2013. Ora che il gioco è disponibile da alcune settimane, abbiamo avuto la possibilità di rispondere ad alcune domande della community di sviluppatori sul funzionamento del gioco. Di seguito, un'analisi delle funzionalità principali e le risposte alle domande che ci vengono poste più spesso.

La traccia

Una sfida abbastanza ovvia che abbiamo dovuto affrontare è stata come creare un gioco per dispositivi mobili basato sul web che funzioni bene su una vasta gamma di dispositivi. I giocatori dovevano essere in grado di iniziare una gara con diversi telefoni e tablet. Un giocatore potrebbe avere un Nexus 4 e voler gareggiare contro un suo amico che aveva un iPad. Dovevamo trovare un modo per stabilire la dimensione comune delle piste per ogni gara. La soluzione doveva comportare l'utilizzo di piste di dimensioni diverse a seconda delle specifiche di ciascun dispositivo incluso nella gara.

Calcolo delle dimensioni delle tracce

Quando ogni giocatore si unisce, le informazioni sul suo dispositivo vengono inviate al server e condivise con gli altri giocatori. Durante la creazione della traccia, questi dati vengono utilizzati per calcolarne l'altezza e la larghezza. Calcoliamo l'altezza trovando l'altezza dello schermo più piccolo, che corrisponde alla larghezza totale di tutti gli schermi. Nell'esempio riportato di seguito, quindi, la traccia avrà una larghezza di 1152 pixel e un'altezza di 519 pixel.

L'area rossa mostra la larghezza e l'altezza totali della traccia per questo esempio.
L'area rossa mostra la larghezza e l'altezza totali della traccia per questo esempio.
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;
}

Come tracciare la traccia

Paper.js è un framework di scripting di grafica vettoriale open source che viene eseguito su Canvas HTML5. Abbiamo scoperto che Paper.js era lo strumento perfetto per creare forme vettoriali per le tracce, quindi abbiamo sfruttato le sue funzionalità per eseguire il rendering delle tracce SVG create in Adobe Illustrator su un elemento <canvas>. Per creare la traccia, la classe TrackModel aggiunge il codice SVG al DOM e raccoglie informazioni sulle dimensioni originali e sul posizionamento da trasmettere al TrackPathView. In questo modo la traccia verrà trascinata su una 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;

Una volta tracciata la traccia, ogni dispositivo individua il proprio offset x in base alla sua posizione nell'ordine di selezione dei dispositivi e posiziona la traccia di conseguenza.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
L&#39;offset x può quindi essere utilizzato per mostrare la parte appropriata della traccia.
L'offset x può quindi essere utilizzato per mostrare la parte appropriata della traccia

Animazioni CSS

Paper.js utilizza molta CPU per tracciare le corsie e questo processo richiederà più o meno tempo sui diversi dispositivi. Per risolvere questo problema, avevamo bisogno di un caricatore per eseguire il loop fino a quando tutti i dispositivi non avranno terminato di elaborare la traccia. Il problema era che qualsiasi animazione basata su JavaScript saltava i frame a causa dei requisiti di CPU di Paper.js. Inserisci le animazioni CSS, che vengono eseguite su un thread dell'interfaccia utente separato, così da animare in modo fluido la lucentezza nel testo "TRACCIA COSTRUIRE".

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

Sprite CSS

Il CSS si è rivelato utile anche per gli effetti in-game. I dispositivi mobili, con la loro potenza limitata, vengono mantenuti occupati animando le auto che corrono sui binari. Quindi, per un ulteriore entusiasmo, abbiamo usato gli sprite come modo per implementare animazioni pre-renderizzate nel gioco. In uno sprite CSS, le transizioni applicano un'animazione basata su passaggi che modifica la proprietà background-position, creando l'esplosione dell'auto.

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

Il problema di questa tecnica è che puoi utilizzare solo i fogli sprite disposti su una singola riga. Per scorrere più righe, l'animazione deve essere concatenata tramite più dichiarazioni dei fotogrammi chiave.

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

Il rendering delle auto

Come per qualsiasi gioco di corse automobilistiche, sapevamo che era importante dare all'utente una sensazione di accelerazione e maneggevolezza. Applicare un livello di trazione diverso era importante per l'equilibrio del gioco e per il fattore di divertimento, in modo che, una volta che un giocatore avesse preso dimestichezza con le leggi fisiche, potessero avere un senso di realizzazione e diventare un pilota migliore.

Ancora una volta abbiamo chiamato Paper.js, che include un'ampia gamma di utility matematiche. Abbiamo utilizzato alcuni dei suoi metodi per spostare l'auto lungo il percorso, regolandone al contempo la posizione e la rotazione a ogni fotogramma.

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

Mentre ottimizzavamo il rendering delle auto, abbiamo riscontrato un aspetto interessante. Su iOS, le prestazioni ottimali sono state ottenute applicando una trasformazione translate3d all'auto:

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

Su Chrome per Android, le prestazioni migliori sono state ottenute calcolando i valori della matrice e applicando una trasformazione della matrice:

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

Mantenere i dispositivi sincronizzati

La parte più importante (e difficile) dello sviluppo è stata garantire la sincronizzazione del gioco tra i dispositivi. Abbiamo pensato che gli utenti potessero perdonare se a volte un'auto saltasse qualche frame a causa di una connessione lenta, ma non sarebbe stato molto divertente se la tua auto saltava in macchina e appare su più schermi contemporaneamente. Risolvere questo problema ha richiesto tantissimi tentativi ed errori, ma alla fine abbiamo scelto alcuni trucchi che hanno funzionato.

Calcolo della latenza

Il punto di partenza per la sincronizzazione dei dispositivi è sapere quanto tempo impiega per ricevere i messaggi dall'inoltro di Compute Engine. Il problema è che gli orologi di ogni dispositivo non saranno mai completamente sincronizzati. Per risolvere il problema, dovevamo trovare la differenza di tempo tra il dispositivo e il server.

Per trovare lo scarto tra il dispositivo e il server principale, inviamo un messaggio con il timestamp attuale del dispositivo. Il server risponderà con il timestamp originale insieme al timestamp del server. Utilizziamo la risposta per calcolare la differenza effettiva nel tempo.

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

Questa operazione non è sufficiente, poiché il round trip al server non è sempre simmetrico, il che significa che potrebbe volerci più tempo prima che la risposta arrivi al server rispetto a quanto dovrebbe restituire il server. Per evitare questo problema, interrompiamo più volte il server, prendendo il risultato mediano. Questo ci fa arrivare entro 10 ms dalla differenza effettiva tra dispositivo e server.

Accelerazione/decelerazione

Quando il giocatore 1 preme o rilascia lo schermo, l'evento di accelerazione viene inviato al server. Una volta ricevuti, il server aggiunge il timestamp attuale e trasmette i dati a ogni altro player.

Quando un dispositivo riceve un evento di tipo "accelerazione attivata" o "disattivato", possiamo utilizzare l'offset del server (calcolato in precedenza) per determinare il tempo necessario per la ricezione del messaggio. Questo è utile perché il giocatore 1 potrebbe ricevere il messaggio in 20 ms, ma il giocatore 2 potrebbe impiegare 50 ms per riceverlo. Di conseguenza, l'auto si trova in due posti diversi, dal momento che il dispositivo 1 avvia l'accelerazione prima.

Possiamo dedicare il tempo necessario per ricevere l'evento e convertirlo in frame. A 60 fps, ogni frame ha una durata di 16,67 ms, quindi possiamo aggiungere più velocità (accelerazione) o attrito (decelerazione) all'auto per tenere conto dei fotogrammi mancanti.

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

Nell'esempio precedente, se il giocatore 1 ha l'auto sullo schermo e il tempo necessario per ricevere il messaggio è inferiore a 75 ms, verrà regolata la velocità dell'auto, accelerandola per compensare la differenza. Se il dispositivo non è presente sullo schermo o se il messaggio ha richiesto troppo tempo, eseguirà la funzione di rendering e farà saltare l'auto dove deve essere.

Sincronizzazione delle auto

Anche dopo aver tenuto conto della latenza in accelerazione, l'auto potrebbe comunque non essere sincronizzata e apparire su più schermi contemporaneamente, in particolare durante il passaggio da un dispositivo all'altro. Per evitare ciò, gli eventi di aggiornamento vengono inviati di frequente per mantenere le auto nella stessa posizione sul circuito su tutti gli schermi.

La logica è che, ogni 4 frame, se l'auto è visibile sullo schermo, quel dispositivo invia i suoi valori a ciascuno degli altri dispositivi. Se l'auto non è visibile, l'app aggiorna i valori con quelli ricevuti e poi avanza in base al tempo necessario per ricevere l'evento di aggiornamento.

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

Conclusione

Non appena abbiamo saputo del concetto di Racer, abbiamo capito che poteva essere un progetto molto speciale. Abbiamo rapidamente creato un prototipo che ci ha dato un'idea approssimativa di come superare la latenza e le prestazioni della rete. È stato un progetto impegnativo che ci ha tenuto impegnato a tarda notte e nei lunghi fine settimana, ma è stato bellissimo quando la partita ha iniziato a prendere forma. In definitiva, il risultato finale è molto soddisfacente. L'idea di Google Creative Lab superava i limiti della tecnologia dei browser in modo divertente e, come sviluppatori, non potevamo chiedere di più.