Практический пример — Building Racer

Введение

Racer — это мобильный эксперимент Chrome с веб-интерфейсом, разработанный Active Theory . До 5 друзей могут подключить свои телефоны или планшеты и соревноваться на всех экранах. Вооружившись концепцией, дизайном и прототипом от Google Creative Lab, а также звуком от Plan8, мы работали над сборками в течение 8 недель, прежде чем запустить их на I/O 2013. Теперь, когда игра существует уже несколько недель, у нас есть шанс ответить на несколько вопросов сообщества разработчиков о том, как это работает. Ниже приведено описание основных функций и ответы на вопросы, которые нам чаще всего задают.

Трек

Довольно очевидная проблема, с которой мы столкнулись, заключалась в том, как создать мобильную веб-игру, которая хорошо работала бы на самых разных устройствах. Игрокам нужно было иметь возможность создавать гонки на разных телефонах и планшетах. У одного игрока может быть Nexus 4, и он захочет соревноваться со своим другом, у которого есть iPad. Нам нужно было найти способ определить общий размер трассы для каждой гонки. Решение должно было включать использование гусениц разного размера в зависимости от характеристик каждого устройства, участвующего в гонке.

Расчет размеров пути

Когда каждый игрок присоединяется, информация об его устройстве отправляется на сервер и передается другим игрокам. При построении трассы эти данные используются для расчета высоты и ширины трассы. Высоту мы вычисляем, находя высоту наименьшего экрана, а ширину — это суммарная ширина всех экранов. Итак, в примере ниже дорожка будет иметь ширину 1152 пикселя и высоту 519 пикселей.

Красная область показывает общую ширину и высоту дорожки для этого примера.
Красная область показывает общую ширину и высоту дорожки для этого примера.
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;
}

Рисование трека

Paper.js — это среда разработки сценариев векторной графики с открытым исходным кодом, работающая поверх HTML5 Canvas. Мы обнаружили, что Paper.js — идеальный инструмент для создания векторных фигур для дорожек, поэтому мы использовали его возможности для рендеринга дорожек SVG, созданных в Adobe Illustrator, на элементе <canvas> . Чтобы создать дорожку, класс TrackModel добавляет код SVG в DOM и собирает информацию об исходных размерах и положении, которая передается в TrackPathView , который отрисовывает дорожку на холсте.

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;

После рисования дорожки каждое устройство находит свое смещение по оси X на основе своего положения в порядке расположения устройств и соответствующим образом позиционирует дорожку.

var x = 0;

for (var i = 0; i < screens.length; i++) {
  if (i < PLAYER_INDEX) {
    x += screens[i].w;
  }
}
Затем смещение по оси X можно использовать для отображения соответствующей части трека.
Затем смещение по оси X можно использовать для отображения соответствующей части трека.

CSS-анимации

Paper.js использует много ресурсов ЦП для рисования полос движения, и этот процесс займет больше или меньше времени на разных устройствах. Чтобы справиться с этим, нам нужен был загрузчик, который будет выполнять цикл до тех пор, пока все устройства не завершат обработку трека. Проблема заключалась в том, что любая анимация на основе JavaScript пропускала кадры из-за требований Paper.js к процессору. Введите анимацию CSS, которая выполняется в отдельном потоке пользовательского интерфейса, что позволяет нам плавно анимировать блеск текста «СТРОИТЕЛЬНЫЙ ПУТЬ».

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

CSS-спрайты

CSS также пригодился для создания внутриигровых эффектов. Мобильные устройства с их ограниченной мощностью постоянно заняты анимацией машин, бегущих по рельсам. Поэтому для дополнительного интереса мы использовали спрайты как способ внедрения в игру предварительно отрендеренной анимации. В CSS-спрайте переходы применяют пошаговую анимацию, которая изменяет свойство background-position , создавая взрыв автомобиля.

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

Проблема этого метода в том, что вы можете использовать только листы спрайтов, расположенные в одну строку. Чтобы пройти по нескольким строкам, анимация должна быть связана через несколько объявлений ключевых кадров.

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

Рендеринг автомобилей

Как и в любой гоночной игре, мы знали, что важно дать пользователю ощущение ускорения и управляемости. Применение разной степени тяги было важно для балансировки игры и для развлечения, так что, как только игрок почувствует физику, он получит чувство выполненного долга и станет лучшим гонщиком.

Мы снова обратились к Paper.js, который поставляется с обширным набором математических утилит. Мы использовали некоторые из его методов для перемещения автомобиля по траектории, плавно регулируя положение и вращение автомобиля в каждом кадре.

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

Пока мы оптимизировали рендеринг автомобилей, обнаружили интересный момент. На iOS наилучшая производительность была достигнута за счет применения к машине преобразования translate3d :

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

В Chrome для Android наилучшая производительность была достигнута за счет вычисления значений матрицы и применения матричного преобразования:

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

Синхронизация устройств

Самой важной (и сложной) частью разработки было обеспечить синхронизацию игры на всех устройствах. Мы думали, что пользователи могут простить, если машина время от времени пропускает несколько кадров из-за медленного соединения, но было бы не очень весело, если бы ваша машина прыгала, появляясь на нескольких экранах одновременно. Решение этой проблемы потребовало множества проб и ошибок, но в конце концов мы остановились на нескольких приемах, благодаря которым все заработало.

Расчет задержки

Отправной точкой для синхронизации устройств является знание того, сколько времени потребуется для получения сообщений от ретранслятора Compute Engine. Сложность заключается в том, что часы на каждом устройстве никогда не будут полностью синхронизированы. Чтобы обойти это, нам нужно было найти разницу во времени между устройством и сервером.

Чтобы найти смещение времени между устройством и главным сервером, мы отправляем сообщение с текущей меткой времени устройства. Затем сервер ответит исходной меткой времени вместе с меткой времени сервера. Мы используем ответ для расчета фактической разницы во времени.

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

Сделать это один раз недостаточно, поскольку путь к серверу туда и обратно не всегда симметричен, а это означает, что доставка ответа на сервер может занять больше времени, чем сервер, чтобы вернуть его. Чтобы обойти эту проблему, мы опрашиваем сервер несколько раз, взяв медианный результат. Это позволяет нам определить фактическую разницу между устройством и сервером в пределах 10 мс.

Ускорение/Замедление

Когда Игрок 1 нажимает или отпускает экран, событие ускорения отправляется на сервер. После получения сервер добавляет свою текущую временную метку, а затем передает эти данные всем остальным игрокам.

Когда устройство получает событие «включение ускорения» или «выключение ускорения», мы можем использовать смещение сервера (рассчитанное выше), чтобы узнать, сколько времени потребовалось для получения этого сообщения. Это полезно, поскольку игрок 1 может получить сообщение через 20 мс, а игроку 2 может потребоваться 50 мс, чтобы получить его. Это приведет к тому, что автомобиль окажется в двух разных местах, поскольку устройство 1 начнет ускорение раньше.

Мы можем потратить время, необходимое на получение события, и преобразовать его в кадры. При 60 кадрах в секунду каждый кадр длится 16,67 мс, поэтому мы можем добавить больше скорости (ускорения) или трения (замедления) автомобиля, чтобы учесть пропущенные кадры.

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

В приведенном выше примере, если на экране игрока 1 отображается машина, а время, необходимое для получения сообщения, составляет менее 75 мс, он будет корректировать скорость автомобиля, увеличивая его скорость, чтобы компенсировать разницу. Если устройства нет на экране или сообщение заняло слишком много времени, оно запустит функцию рендеринга и фактически заставит автомобиль прыгнуть туда, где ему нужно.

Синхронизация автомобилей

Даже после учета задержки при ускорении автомобиль все равно мог рассинхронизироваться и появляться на нескольких экранах одновременно; особенно при переходе с одного устройства на другое. Чтобы предотвратить это, события обновления отправляются часто, чтобы автомобили оставались в одном и том же положении на трассе на всех экранах.

Логика заключается в том, что каждые 4 кадра, если автомобиль виден на экране, это устройство отправляет свои значения каждому из других устройств. Если автомобиль не виден, приложение обновляет полученные значения, а затем перемещает автомобиль вперед в зависимости от времени, которое потребовалось для получения события обновления.

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

Заключение

Как только мы услышали концепцию Racer, мы поняли, что это может стать особенным проектом. Мы быстро создали прототип, который дал нам примерное представление о том, как преодолеть задержки и повысить производительность сети. Это был сложный проект, который занимал нас до поздней ночи и долгих выходных, но когда игра начала обретать форму, было прекрасное чувство. В конечном итоге мы очень довольны конечным результатом. Концепция Google Creative Lab в увлекательной форме раздвинула границы браузерных технологий, и мы, как разработчики, не могли желать большего.