案例研究 - 构建赛车手

Active Theory
Active Theory

简介

Racer 是由 Active Theory 开发的一项基于 Web 的移动版 Chrome 实验。最多 5 位好友可以连接他们的手机或平板电脑,在每个屏幕上竞速。在 Google Creative Lab 提供的概念、设计和原型以及 Plan8 提供的音效的加持下,我们在 2013 年 I/O 大会发布前 8 周内对 build 进行了迭代。该游戏已发布数周时间,我们有机会回答开发者社区就其运作方式提出的一些问题。下面详细介绍了主要功能,并解答了我们最常遇到的问题。

轨道

我们面临的一个非常明显的挑战是,如何制作一款能够在各种设备上正常运行的基于 Web 的移动游戏。玩家需要能够使用不同的手机和平板电脑构建比赛。例如,一位玩家使用的是 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 是用于为轨道创建矢量形状的理想工具,因此我们利用其功能在 <canvas> 元素上渲染了在 Adobe Illustrator 中构建的 SVG 轨道。如需创建轨道,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 会使用大量 CPU 处理来绘制轨道车道,并且此过程在不同设备上所需的时间会有所不同。为了解决此问题,我们需要一个加载器,以便在所有设备都完成轨道处理之前循环运行。问题在于,由于 Paper.js 的 CPU 要求,任何基于 JavaScript 的动画都会跳帧。此时,我们引入了 CSS 动画,这些动画在单独的界面线程中运行,让我们能够在“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);
  }
}
}

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

在 Android 版 Chrome 上,通过计算矩阵值并应用矩阵转换,可以实现最佳性能:

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 按下或松开屏幕时,系统会向服务器发送加速度事件。收到该数据后,服务器会添加其当前时间戳,然后将该数据传递给所有其他玩家。

当设备收到“加速开启”或“加速关闭”事件时,我们可以使用服务器偏移量(如上所述)来了解收到该消息所花的时间。这很有用,因为 Player 1 可能需要 20 毫秒才能收到消息,但 Player 2 可能需要 50 毫秒才能收到消息。这会导致汽车位于两个不同的位置,因为设备 1 会更早开始加速。

我们可以获取接收事件所用的时间,并将其转换为帧。在 60fps 时,每帧为 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 的概念以一种有趣的方式突破了浏览器技术的极限,这对我们开发者来说是再好不过了。