個案研究 - 建築賽車

Active Theory
Active Theory

簡介

RacerActive Theory 開發的行動版 Chrome 實驗,以網路為基礎。最多可邀請 5 位好友連線,在每部螢幕上進行比賽。我們運用 Google Creative Lab 提供的概念、設計和原型,以及 Plan8 提供的音效,在 8 週內進行重複建構,並在 2013 年 I/O 大會上推出。遊戲推出幾週後,我們有幸有機會回答開發人員社群提出的幾個有關遊戲運作方式的問題。以下是主要功能的詳細說明,以及常見問題的解答。

軌道

我們面臨的一個明顯挑戰,就是如何製作能在各種裝置上順利運作的行動網頁遊戲。玩家必須能夠使用不同的手機和平板電腦建構賽事。假設一位玩家使用 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 合併圖片

CSS 也非常適合用於遊戲內特效。行動裝置的電力有限,因此必須持續運作才能呈現車輛在軌道上行駛的動畫。因此,為了讓遊戲更有趣,我們使用了 Sprite 來在遊戲中實作預先算繪的動畫。在 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 的概念以有趣的方式突破瀏覽器技術的極限,這正是開發人員所期待的。