Studium przypadku – budowanie wyścigów

Wstęp

Racer to internetowy eksperyment w Chrome na komórki zaprojektowany przez Active Theory. Nawet 5 znajomych może połączyć telefony lub tablety i ścigać się na każdym urządzeniu. Wykorzystaliśmy koncepcję, projekt i prototyp z Google Creative Lab oraz dźwięk z planu Plan8. Przez 8 tygodni, aż do premiery na konferencji I/O w 2013 roku, doskonaliliśmy poszczególne kompilacje. Teraz, gdy gra jest już dostępna od kilku tygodni, mieliśmy okazję odpowiedzieć na kilka pytań społeczności deweloperów na temat jej działania. Poniżej znajdziesz zestawienie najważniejszych funkcji i odpowiedzi na najczęstsze pytania.

Ścieżka

Dość oczywistym wyzwaniem było stworzenie internetowej gry mobilnej, która dobrze działa na wielu różnych urządzeniach. Gracze musieli zbudować wyścig za pomocą różnych telefonów i tabletów. Jeden z graczy może mieć Nexus 4 i chcieć pościgać się ze znajomym, który miał iPada. Musieliśmy wymyślić sposób na określenie wspólnego rozmiaru toru dla każdej rasy. Rozwiązanie wymagało użycia ścieżek o różnych rozmiarach w zależności od parametrów każdego urządzenia biorącego udział w wyścigu.

Obliczanie wymiarów ścieżek

Gdy dołącza do gry, informacje o jego urządzeniu są wysyłane na serwer i udostępniane innym graczom. Te dane są używane do obliczania wysokości i szerokości toru podczas tworzenia toru. Wysokość obliczamy, znajdując wysokość najmniejszego ekranu. Szerokość to łączna szerokość wszystkich ekranów. W podanym niżej przykładzie ścieżka miałaby szerokość 1152 pikseli i wysokość 519 pikseli.

Czerwony obszar pokazuje całkowitą szerokość i wysokość ścieżki w tym przykładzie.
Czerwony obszar pokazuje całkowitą szerokość i wysokość ścieżki w tym przykładzie.
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;
}

Rysowanie toru

Paper.js to platforma open source do tworzenia skryptów graficznych wektorowych, która działa w oparciu o środowisko Canvas HTML5. Uznaliśmy, że Paper.js to idealne narzędzie do tworzenia kształtów wektorowych dla ścieżek. Wykorzystaliśmy więc jego możliwości do renderowania ścieżek SVG utworzonych w programie Adobe Illustrator w elemencie <canvas>. Aby utworzyć ścieżkę, klasa TrackModel dołącza kod SVG do DOM i zbiera informacje o oryginalnych wymiarach i pozycjonowaniu, które mają zostać przekazane do elementu TrackPathView. Spowoduje to narysowanie ścieżki w obiekcie canvas.

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;

Po narysowaniu ścieżki każde urządzenie ustala swoje przesunięcie w kierunku x na podstawie swojego położenia w kolejności tej ścieżki na liście i odpowiednio umieszcza ścieżkę.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
Następnie można użyć przesunięcia X, aby wyświetlić odpowiednią część ścieżki.
Następnie można użyć przesunięcia x, aby wyświetlić odpowiednią część utworu.

Animacje CSS

Rysowanie pasów torów wymaga dużej mocy obliczeniowej procesora.Na różnych urządzeniach ten proces zajmuje mniej lub więcej czasu. Potrzebny nam był program wczytujący, który będzie zapętlał ścieżki do momentu zakończenia przetwarzania ścieżki przez wszystkie urządzenia. Problem polegał na tym, że animacje oparte na języku JavaScript pomijały klatki z powodu wymagań dotyczących procesora w języku Paper.js. Wykorzystaj animacje CSS, które wyświetlają się w osobnym wątku interfejsu, co umożliwia płynne animowanie powstawania w tekście „ŚCIEŻKA BUDŻETU”.

.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'y CSS

Usługi porównywania cen przydały się też do tworzenia efektów w grze. Urządzenia mobilne, które mają ograniczoną moc, są zajęte animacją samochodów jadących po torach. Aby jeszcze bardziej zwiększyć zainteresowanie, zastosowaliśmy sprite’y jako sposób na wdrożenie w grze wstępnie renderowanych animacji. W sprite'ach CSS przejścia korzystają z animacji krokowej, która zmienia właściwość background-position, powodując eksplozję samochodu.

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

Problem z tą techniką polega na tym, że możesz używać tylko arkuszy sprite ułożonych w jednym rzędzie. Żeby przechodzić między wieloma wierszami w pętli, animacja musi być powiązana z wieloma deklaracjami klatek kluczowych.

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

Renderowanie samochodów

Tak jak w przypadku każdej gry wyścigowej, zdawaliśmy sobie sprawę, że ważne jest zapewnienie użytkownikowi poczucia przyspieszenia i obsługi. Wykorzystanie różnych strategii było ważne, ponieważ zapewniało równowagę w grze i zapewniało dobrą zabawę – gdy gracz ma już wyobrażenie o prawie fizyki, czuje, że osiąga sukcesy, i staje się lepszym wyścigiem.

Po raz kolejny skorzystaliśmy z pakietu Paper.js, który udostępnia szeroką gamę narzędzi matematycznych. Za pomocą niektórych metod przesuwaliśmy samochód po ścieżce przy jednoczesnym dostosowywaniu pozycji i płynnym obrocie samochodu w każdej ramce.

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

Podczas optymalizacji renderowania samochodu napotkaliśmy ciekawy punkt. W systemie iOS najlepszą wydajność uzyskano dzięki zastosowaniu przekształcenia translate3d w samochodzie:

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

W przypadku Chrome na Androida najlepszą wydajność uzyskano, obliczając wartości macierzy i stosując przekształcenie macierzy:

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

Utrzymywanie synchronizacji urządzeń

Najważniejszym (i najtrudniejszym) aspektem tworzenia gry było zapewnienie synchronizacji gry między urządzeniami. Sądziliśmy, że użytkownicy mogą czuć się zaniepokojeni, jeśli samochód czasami pomija kilka klatek z powodu wolnego połączenia, ale nic nie szkodzi, gdy samochód skacze i pojawia się na kilku ekranach jednocześnie. Rozwiązanie tego problemu wymagało metody prób i błędów, ale w końcu zdecydowaliśmy się na kilka sztuczek, które pomogły nam w osiągnięciu sukcesu.

Obliczanie czasu oczekiwania

Punktem początkowym synchronizacji urządzeń jest określenie czasu potrzebnego na odebranie wiadomości z przekaźnika Compute Engine. Problem w tym, że zegary na wszystkich urządzeniach nigdy nie będą w pełni zsynchronizowane. Aby obejść ten problem, musieliśmy znaleźć różnicę w czasie między urządzeniem a serwerem.

Aby określić przesunięcie czasu między urządzeniem a serwerem głównym, wysyłamy wiadomość z aktualną sygnaturą czasową urządzenia. W odpowiedzi serwer wysyła oryginalną sygnaturę czasową wraz z sygnaturą czasową serwera. Na podstawie odpowiedzi obliczamy rzeczywistą różnicę w czasie.

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

Wykonanie tych czynności nie wystarczy, ponieważ przesyłanie danych w obie strony do serwera nie zawsze jest symetryczne, co oznacza, że uzyskanie odpowiedzi z serwera może potrwać dłużej niż jej zwrócenie przez serwer. Aby go obejść, wielokrotnie przeprowadzamy sondowanie serwera, uwzględniając wynik mediany. Oznacza to, że różnica między urządzeniem a serwerem wynosi 10 ms.

Przyspieszenie/wypalenie

Gdy odtwarzacz 1 naciska lub puści ekran, zdarzenie przyspieszenia jest wysyłane do serwera. Po ich otrzymaniu serwer dodaje swoją bieżącą sygnaturę czasową i przekazuje dane do każdego innego gracza.

Gdy urządzenie odbiera zdarzenie „przyspieszanie włączone” lub „przyspieszanie wyłączone”, możemy użyć przesunięcia serwera (obliczonego powyżej), aby dowiedzieć się, ile czasu zajęło odebranie tej wiadomości. Jest to przydatne, ponieważ Odtwarzacz 1 może otrzymać wiadomość w ciągu 20 ms, ale Odtwarzacz 2 – w ciągu 50 ms. Samochód znalazłby się wtedy w dwóch różnych miejscach, ponieważ urządzenie 1 włączyło przyspieszanie.

Czas potrzebny na odbiór zdarzenia i przekształcenie go w ramki Przy 60 kl./s każda klatka trwa 16,67 ms, co pozwala nam zwiększyć prędkość (przyspieszenie) lub tarcie (zmniejszenie) samochodu, aby uwzględnić pominięte klatki.

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

W powyższym przykładzie, jeśli Odtwarzacz 1 ma samochód na ekranie, a czas potrzebny na otrzymanie wiadomości wynosi mniej niż 75 ms, dostosuje prędkość samochodu, by zrekompensować stratę. Jeśli urządzenia nie ma na ekranie lub wiadomość trwała zbyt długo, zostanie uruchomiona funkcja renderowania, dzięki której samochód podskoczy w odpowiednie miejsce.

Synchronizacja samochodów

Nawet po uwzględnieniu opóźnienia w przyspieszeniu samochód może nie być zsynchronizowany i pojawiać się na kilku ekranach jednocześnie, zwłaszcza podczas przechodzenia z jednego urządzenia na drugie. Aby temu zapobiec, często wysyłamy zdarzenia aktualizacji, aby samochody znajdowały się w tym samym położeniu na torze na wszystkich ekranach.

Logika polega na tym, że co 4 klatki (jeśli samochód jest widoczny na ekranie), urządzenie wysyła swoje wartości do pozostałych urządzeń. Jeśli samochód nie jest widoczny, aplikacja aktualizuje wartości przy użyciu otrzymanych wartości, a następnie przesuwa samochód do przodu na podstawie czasu potrzebnego na aktualizację.

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

Podsumowanie

Gdy tylko usłyszeliśmy o koncepcji Racer, od razu wiedzieliśmy, że ma ona potencjał. Szybko zbudowaliśmy prototyp, który dał nam ogólne pojęcie o tym, jak radzić sobie z opóźnieniami i wydajnością sieci. Był to trudny projekt, który zajmował nas do późnych godzin nocnych i długich weekendów, ale to było świetne uczucie, gdy gra nabierała tempa. Jesteśmy bardzo zadowoleni z efektu końcowego. Pomysł Google Creative Lab przeniósł ograniczenia przeglądarek w zabawny sposób, a jako programiści nie byliśmy w stanie prosić o więcej.