個案研究 - 建築賽車

Active Theory
Active Theory

簡介

RacerActive Theory 開發的網路式 Chrome 行動裝置實驗功能。最多可讓 5 位好友連結手機或平板電腦,在各種裝置上競賽。考量到 Google 創意研究室的概念、設計和原型設計,以及 Plan8 提供的音效,我們在 2013 年 I/O 大會上路前持續 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 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 處理程序來繪製軌道車道,而且在不同裝置上需要花上更多時間,才能完成這項程序。為處理這種情況,我們需要載入載入器來循環播放,直到所有裝置完成歷程處理作業。問題在於,所有以 JavaScript 為基礎的動畫都會因為 Paper.js 的 CPU 需求而略過影格。輸入 CSS 動畫,該動畫會在個別 UI 執行緒中執行,讓我們能夠流暢地在「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 Sprite

CSS 也非常適合用於遊戲內特效。電力有限,但行動裝置正忙著為中跑車執行動畫效果。為了進一步刺激這款遊戲,我們使用 Sprite 做為遊戲的預先算繪動畫實作方法。在 CSS Sprite 中,轉換會套用步驟式動畫,藉此變更 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 工作表。為了在多列之間循環播放,動畫必須鏈結多個主要畫面格宣告。

#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 呼叫 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 按下或放開螢幕時,加速事件會傳送至伺服器。收到資料後,伺服器會加入目前的時間戳記,然後將該資料傳送給所有其他玩家。

當裝置收到「加速開啟」或「加速啟動」事件時,我們可以利用伺服器偏移 (如上所示) 找出接收該訊息所需的時間。這很實用,因為玩家 1 可能會在 20 毫秒內收到訊息,但是播放器 2 可能需要 50 毫秒才能接收訊息。這會導致車輛停在兩個不同地方,因為裝置 1 會更快啟動加速功能。

我們可以花費時間接收事件,並將該事件轉換為影格。60 FPS 時,每個影格大小為 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 廣告素材研究室的概念,以有趣的方式使瀏覽器技術的極限,而開發人員正是時候,以充滿樂趣。