Étude de cas : Building Racer

Active Theory
Active Theory

Présentation

Racer est une expérience Web mobile Chrome Experiment développée par Active Theory. Jusqu'à cinq amis peuvent connecter leur téléphone ou leur tablette pour courir sur tous les écrans. En nous appuyant sur le concept, la conception et le prototype du Google Creative Lab et sur les sons de Plan8, nous avons itéré sur les constructions pendant 8 semaines avant le lancement lors de la conférence Google I/O 2013. Maintenant que le jeu est en ligne depuis quelques semaines, nous avons eu l'occasion de répondre à quelques questions de la communauté des développeurs sur son fonctionnement. Vous trouverez ci-dessous le détail des principales fonctionnalités, ainsi que les réponses aux questions qui nous sont les plus souvent posées.

La piste

Un défi évident est celui de créer un jeu Web mobile qui fonctionne bien sur une grande variété d'appareils. Les joueurs devaient pouvoir construire une course avec différents téléphones et tablettes. Un joueur peut avoir un Nexus 4 et vouloir affronter son ami qui possède un iPad. Nous devions trouver un moyen de déterminer une taille de piste commune pour chaque course. La solution consistait à utiliser des circuits de différentes tailles en fonction des caractéristiques de chaque appareil inclus dans la course.

Calcul des dimensions d'un titre

Lorsque chaque joueur rejoint le jeu, des informations concernant son appareil sont envoyées au serveur et partagées avec les autres joueurs. Lors de la construction de la voie, ces données sont utilisées pour calculer sa hauteur et sa largeur. Nous calculons la hauteur en trouvant la hauteur du plus petit écran, et la largeur est la largeur totale de tous les écrans. Ainsi, dans l'exemple ci-dessous, la piste aura une largeur de 1 152 pixels et une hauteur de 519 pixels.

La zone rouge indique la largeur et la hauteur totales de la piste pour cet exemple.
La zone rouge indique la largeur et la hauteur totales de la voie pour cet exemple.
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;
}

Dessiner la voie

Paper.js est un framework de script de graphiques vectoriels Open Source qui s'exécute sur HTML5 Canvas. Nous avons constaté que Paper.js était l'outil idéal pour créer des formes vectorielles pour les pistes. Nous avons donc utilisé ses fonctionnalités pour afficher les pistes SVG créées dans Adobe Illustrator dans un élément <canvas>. Pour créer le tracé, la classe TrackModel ajoute le code SVG au DOM, puis collecte des informations sur les dimensions et le positionnement d'origine à transmettre à TrackPathView, qui dessine le tracé sur un canevas.

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;

Une fois le tracé tracé, chaque appareil trouve son décalage X en fonction de sa position dans l'ordre des appareils, puis positionne le tracé en conséquence.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
Le décalage x peut ensuite être utilisé pour montrer la partie appropriée de la piste.
Le décalage X peut ensuite être utilisé pour afficher la partie appropriée de la piste.

Animations CSS

Paper.js s'appuie sur une grande quantité de traitements processeur pour tracer les voies. Ce processus prend plus ou moins de temps en fonction des appareils. Pour gérer cela, nous avions besoin d'un chargeur pour lire en boucle jusqu'à ce que tous les appareils aient fini de traiter le titre. Le problème était que toute animation basée sur JavaScript sautait des images en raison des exigences de Paper.js en matière de processeur. Utilisez des animations CSS, qui s'exécutent sur un thread UI distinct, ce qui nous permet d'animer l'éclat de l'image dans le texte "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

Le CSS s'est également avéré pratique pour les effets dans les jeux. Les appareils mobiles, dont la puissance est limitée, animent les voitures qui circulent sur les rails. Pour susciter encore plus d'enthousiasme, nous avons utilisé des lutins afin d'implémenter des animations pré-affichées dans le jeu. Dans un sprite CSS, les transitions appliquent une animation basée sur les étapes qui modifie la propriété background-position, créant ainsi l'explosion de la voiture.

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

Le problème avec cette technique est que vous ne pouvez utiliser que des feuilles de sprites disposées sur une seule ligne. Pour lire en boucle plusieurs lignes, l'animation doit être enchaînée via plusieurs déclarations d'images clés.

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

Rendu des voitures

Comme pour n'importe quel jeu de course automobile, nous savions qu'il était important de donner aux utilisateurs une sensation d'accélération et de maniabilité. Il était important d'appliquer un niveau d'adhérence différent pour équilibrer le jeu et s'amuser. Ainsi, une fois que le joueur se serait familiarisé avec la physique, il ressentait un sentiment d'accomplissement et pouvait devenir un meilleur coureur.

Nous avons de nouveau fait appel à Paper.js, qui contient de nombreux utilitaires mathématiques. Nous avons utilisé certaines de ses méthodes pour déplacer la voiture le long de la voie, tout en ajustant la position et la rotation de la voiture de manière fluide à chaque cadre.

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

En optimisant le rendu de la voiture, nous avons identifié un point intéressant. Sur iOS, les performances optimales ont été obtenues en appliquant une transformation translate3d à la voiture:

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

Sur Chrome pour Android, les performances optimales ont été obtenues en calculant les valeurs matricielles et en appliquant une transformation matricielle:

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

Synchroniser les appareils

La partie la plus importante (et la plus difficile) du développement consistait à s'assurer que le jeu était synchronisé sur tous les appareils. Nous pensions que les utilisateurs se contenteraient de sauter quelques images de temps en temps en raison d'une connexion lente, mais ce ne serait pas très amusant si votre voiture saute et s'affiche sur plusieurs écrans à la fois. Pour résoudre ce problème, nous avons fait beaucoup d'essais et d'erreurs, mais nous avons fini par trouver quelques astuces qui ont fonctionné.

Calculer la latence

Pour synchroniser des appareils, le point de départ est de savoir combien de temps il faut pour recevoir les messages du relais Compute Engine. La partie délicate est que les horloges de chaque appareil ne seront jamais complètement synchronisées. Pour contourner ce problème, nous devions trouver le délai entre l'appareil et le serveur.

Pour connaître le décalage horaire entre l'appareil et le serveur principal, nous envoyons un message contenant l'horodatage actuel de l'appareil. Le serveur répond alors avec le code temporel d'origine ainsi que celui du serveur. Cette réponse nous permet de calculer la différence de temps réelle.

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

Cette opération une seule fois n'est pas suffisante, car l'aller-retour vers le serveur n'est pas toujours symétrique, ce qui signifie que la réponse peut mettre plus de temps à arriver au serveur qu'au serveur pour la renvoyer. Pour contourner ce problème, nous interrogeons le serveur plusieurs fois, en prenant le résultat médian. La différence réelle entre l'appareil et le serveur est de 10 ms.

Accélération/Accélération

Lorsque le joueur 1 appuie ou relâche sur l'écran, l'événement d'accélération est envoyé au serveur. Une fois la notification reçue, le serveur ajoute son code temporel actuel, puis transmet ces données à tous les autres joueurs.

Lorsqu'un appareil reçoit un événement "Accélérer le" ou "Accélérer la désactivation", nous pouvons utiliser le décalage du serveur (calculé ci-dessus) pour déterminer le temps de réception du message. Cette opération est utile, car le lecteur 1 peut recevoir le message en 20 ms, tandis que le joueur 2 peut mettre 50 ms à le recevoir. La voiture se trouverait alors à deux endroits différents, car l'appareil 1 lancerait l'accélération plus tôt.

Nous pouvons prendre le temps nécessaire pour recevoir l'événement et le convertir en frames. À 60 FPS, chaque image dure 16,67 ms.Nous pouvons donc ajouter plus de vitesse (accélération) ou de friction (décalage) sur la voiture pour tenir compte des images qu'elle a manquées.

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

Dans l'exemple ci-dessus, si le lecteur 1 affiche la voiture à l'écran et que le temps de réception du message est inférieur à 75 ms, il ajuste la vitesse de la voiture et l'accélère pour compenser la différence. Si l'appareil ne s'affiche pas à l'écran ou si le message a pris trop de temps, il exécute la fonction de rendu et fait passer la voiture à l'endroit où elle doit se trouver.

Synchroniser les voitures

Même si l'on tient compte de la latence d'accélération, la voiture peut toujours se désynchroniser et apparaître sur plusieurs écrans à la fois, en particulier lors du passage d'un appareil à un autre. Pour éviter cela, des événements de mise à jour sont envoyés fréquemment afin que les voitures restent à la même position sur le circuit sur tous les écrans.

La logique est que, tous les quatre cadres, si la voiture est visible à l'écran, cet appareil envoie ses valeurs à chacun des autres appareils. Si la voiture n'est pas visible, l'application met à jour les valeurs avec celles reçues, puis fait avancer la voiture en fonction du temps nécessaire pour obtenir l'événement de mise à jour.

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

Conclusion

Dès que nous avons entendu parler du concept de Racer, nous avons compris qu'il pourrait s'agir d'un projet très spécial. Nous avons rapidement construit un prototype qui nous a donné une idée approximative de la manière de surmonter la latence et les performances du réseau. C'était un projet difficile qui nous a occupés tard le soir et pendant les longs week-ends, mais c'était un sentiment merveilleux quand le jeu a commencé à prendre forme. En fin de compte, nous sommes très satisfaits du résultat final. Le concept du Google Creative Lab repousse les limites de la technologie des navigateurs de façon ludique. En tant que développeurs, nous ne pouvions pas en demander plus.