事例紹介 - レーサーの構築

はじめに

Racer は、Active Theory が開発したウェブベースの Chrome 試験運用版です。最大 5 人の友達が、自分のスマートフォンやタブレットを接続して、あらゆる画面でレースに参加できます。Google Creative Lab のコンセプト、デザイン、プロトタイプに加え、Plan8 のサウンドを武器に、I/O 2013 でのリリースまで 8 週間にわたってビルドの繰り返しを重ねました。リリースから数週間が経過し、ゲームの仕組みに関するデベロッパー コミュニティからの質問に答える機会を設けました。以下に、主な機能の概要と、よくある質問への回答をまとめました。

トラック

私たちが直面した非常に明白な課題は、さまざまなデバイスで適切に動作するウェブベースのモバイルゲームを作成する方法でした。プレーヤーは、さまざまなスマートフォンやタブレットでレースを構築できる必要がありました。あるプレーヤーが Nexus 4 を所有していて、iPad を持っていた友人と競いたいと考えているとします。レースごとに共通のトラックサイズを決定する方法を見つける必要がありました。この解決策では、レースに含まれる各デバイスの仕様に応じて、異なるサイズのトラックを使用する必要がありました。

トラックのサイズの計算

各プレーヤーが参加するたびに、そのプレーヤーのデバイスに関する情報がサーバーに送信され、他のプレーヤーと共有されます。トラックを作成すると、このデータを使用してトラックの高さと幅が計算されます。高さは、最も小さい画面の高さを求めることで計算され、幅はすべての画面の幅の合計です。したがって、以下の例では、トラックの幅は 1,152 ピクセル、高さは 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 はトラックのベクターシェイプを作成するのに最適なツールであることがわかったため、Paper.js の機能を使用して、Adobe Illustrator で作成した SVG トラックを <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 はトラックレーンを描くために大量の CPU 処理を使用するため、このプロセスにかかる時間はデバイスによって異なります。これに対処するには、すべてのデバイスがトラックの処理を完了するまでループするローダが必要でした。問題は、Paper.js の CPU 要件により、JavaScript ベースのアニメーションではフレームがスキップされることでした。別の UI スレッドで実行される 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;
  }
}

この手法の問題は、1 行に配置されたスプライト シートしか使用できないことです。複数の行をループするには、複数のキーフレーム宣言でアニメーションを連結する必要があります。

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

デバイスの同期を維持する

開発において最も重要な(そして難しい)部分は、ゲームがデバイス間で同期されるようにすることでした。Google は、接続速度が遅いために車が数フレームをスキップすることがあっても許容できると考えましたが、車がジャンプして一度に複数の画面に表示された場合は、あまり楽しくないでしょう。この問題の解決には多くの試行錯誤が必要でしたが、最終的にはいくつかのコツを解決して解決することができました。

レイテンシの計算

デバイスを同期するには、まず Compute Engine リレーからメッセージを受信するまでにかかる時間を把握する必要があります。厄介なのは、各デバイスのクロックが完全には同期しないことです。これを回避するには、デバイスとサーバーの時間差を見つける必要がありました。

デバイスとメインサーバーとの間のタイム オフセットを見つけるために、現在のデバイスのタイムスタンプを含むメッセージを送信します。サーバーは元のタイムスタンプとサーバーのタイムスタンプを返します。レスポンスを使用して実際の時間差を計算します。

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

サーバーへのラウンド トリップは必ずしも対称的ではないため、これを 1 回行うだけでは不十分です。つまり、レスポンスがサーバーに到達するまでに、サーバーがレスポンスを返すまでの時間よりも長くなる可能性があります。これを回避するために、サーバーを複数回ポーリングして結果の中央値を取得します。これにより、デバイスとサーバーの実際の差から 10 ミリ秒以内になります。

加速度/減速

プレーヤー 1 が画面を押すか離すと、アクセラレーション イベントがサーバーに送信されます。受信すると、サーバーは現在のタイムスタンプを追加し、そのデータを他のすべてのプレーヤーに渡します。

「accelerate on」イベントまたは「accelerate off」イベントをデバイスが受信すると、上記のサーバー オフセットを使用して、そのメッセージの受信にかかった時間を調べることができます。プレーヤー 1 はメッセージを受信するのに 20 ミリ秒かかるが、プレーヤー 2 はメッセージを受信するのに 50 ミリ秒かかる場合があるため、これは便利です。この場合、デバイス 1 がアクセルをより早く開始するので、自動車が 2 つの場所に存在することになります。

イベントの受信にかかった時間を計測し、フレームに変換します。60 fps では、各フレームは 16.67 ms です。したがって、見逃したフレームを考慮して、車の速度(加速度)または摩擦(減速)をさらに追加できます。

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 のコンセプトは、楽しい方法でブラウザ テクノロジーの限界を押し広げました。デベロッパーとしては、これ以上の要望は得られませんでした。