案例研究 - 构建赛车手

Active Theory
Active Theory

简介

Racer 是由 Active Theory 开发的网页版移动版 Chrome 实验性功能。最多 5 位好友可以连接他们的手机或平板电脑,在不同屏幕上比赛。在 2013 年 I/O 大会发布前,我们以来自 Google Creative Lab 的概念、设计和原型以及 Plan8 中的音效为基础,对 build 进行了为期 8 周的迭代。鉴于这款游戏已推出几周,我们有机会回答开发者社区提出的一些有关其运作方式的问题。以下是对主要功能的详细介绍,以及对我们最常被问及的问题的解答。

赛道

我们面临的一个显而易见的挑战是,如何制作能够在各种设备上顺畅运行的网页移动游戏。选手需要能够使用不同的手机和平板电脑进行比赛。一位玩家可能拥有 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 画布上运行的开源矢量图形脚本脚本框架。我们发现 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 动画,这种动画在单独的界面线程上运行,可让我们顺畅地为“构建轨道”文本上的光泽添加动画效果。

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

当设备收到“accelerate on”或“accelerate off”事件时,我们能够使用服务器偏移值(在上文中计算出来)计算出邮件的接收时间。这很有用,因为玩家 1 可能会在 20 毫秒内收到消息,而玩家 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 创意实验室的概念以一种有趣的方式推动了浏览器技术的极限,对开发者而言,我们无法对其提出更多要求。