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

はじめに

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 が最適なツールであることがわかったため、その機能を使用して、Adobe Illustrator で作成した SVG トラックを <canvas> 要素にレンダリングしました。トラックを作成するために、TrackModel クラスは SVG コードを DOM に追加し、元のサイズと配置に関する情報を収集して TrackPathView に渡します。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)';

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;

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

加速/減速

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

デバイスが「加速オン」または「加速オフ」イベントを受信すると、(上記で計算した)サーバー オフセットを使用して、そのメッセージが受信されるまでにかかった時間を把握できます。これは、プレーヤー 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 のコンセプトは、楽しい方法でブラウザ技術の限界を押し広げました。デベロッパーとしてこれ以上望むことはありません。