第3版が公開されました。こちらで読んでください

第15章
プロジェクト: プラットフォームゲーム

すべての現実とはゲームである。

Iain Banks, The Player of Games

私のコンピューターへの最初の関心は、多くの子供たちと同様に、コンピューターゲームから始まりました。私は、自分が操作できる小さなコンピューターシミュレートされた世界、そして物語が(ある程度)展開する世界に惹きつけられました。それは、自分が想像力を投影できる方法によるものだったと思います。実際的な可能性というよりは。

ゲームプログラミングのキャリアを誰にも勧めたくありません。音楽業界と同様に、それを仕事にしたい熱心な若者と、実際の需要の間に大きな隔たりがあり、かなり不健全な環境を作り出しています。しかし、楽しみのためにゲームを作るのは楽しいものです。

この章では、シンプルなプラットフォームゲームの実装について説明します。プラットフォームゲーム(または「ジャンプアンドラン」ゲーム)は、プレイヤーが世界の中をキャラクターを移動させるゲームであり、多くの場合、二次元で側面から見たもので、多くのジャンプや障害物を乗り越えることが求められます。

ゲーム

私たちのゲームは、Thomas PalefによるDark Blueを大まかにベースにしています。このゲームを選んだ理由は、面白くてミニマリストであり、それほど多くのコードを書くことなく構築できるからです。これはこのようになります。

The game Dark Blue

暗い四角形はプレイヤーを表しており、その仕事は黄色の四角形(コイン)を集めながら赤いもの(溶岩?)を避けることです。すべてのコインを集めると、レベルがクリアされます。

プレイヤーは左右の矢印キーで歩き回り、上矢印キーでジャンプできます。ジャンプはこのゲームのキャラクターの特技です。自分の身長の数倍の高さに到達でき、空中で方向を変えることができます。これは完全に現実的ではありませんが、プレイヤーに画面上のアバターを直接制御しているという感覚を与えてくれます。

このゲームは、グリッドのように配置された固定された背景と、その背景の上に重ねられた可動要素で構成されています。グリッド上の各フィールドは、空、固体、または溶岩のいずれかです。可動要素は、プレイヤー、コイン、そして特定の種類の溶岩です。第7章の人工生命シミュレーションとは異なり、これらの要素の位置はグリッドに制限されません。座標は小数になる可能性があり、スムーズな動きが可能になります。

技術

ゲームの表示にはブラウザのDOMを使用し、キーイベントを処理することでユーザー入力を読み取ります。

画面とキーボード関連のコードは、このゲームを構築するために必要な作業のごく一部です。すべてが色のついた四角形のように見えるため、描画は簡単です。DOM要素を作成し、スタイルを使用して背景色、サイズ、位置を指定します。

背景は変化しない正方形のグリッドなので、テーブルとして表現できます。自由に動く要素は、絶対位置指定された要素を使用してその上に重ねることができます。

ゲームやその他のプログラムでは、グラフィックをアニメーション化し、目に見える遅延なしにユーザー入力に応答する必要があるため、効率性が重要です。DOMはもともと高性能グラフィックス用に設計されていませんでしたが、実際には予想以上に優れています。第13章でいくつかのアニメーションを見ました。最新のコンピューターでは、最適化についてあまり考えなくても、このようなシンプルなゲームはうまく動作します。

次の章では、グラフィックを描画するより従来の方法を提供する別のブラウザテクノロジーである<canvas>タグについて説明します。DOM要素ではなく、形状とピクセル単位で動作します。

レベル

第7章では、二次元グリッドを記述するために文字列の配列を使用しました。ここでは同じようにできます。これにより、レベルエディターを作成することなくレベルを設計できます。

単純なレベルは次のようになります。

var simpleLevelPlan = [
  "                      ",
  "                      ",
  "  x              = x  ",
  "  x         o o    x  ",
  "  x @      xxxxx   x  ",
  "  xxxxx            x  ",
  "      x!!!!!!!!!!!!x  ",
  "      xxxxxxxxxxxxxx  ",
  "                      "
];

固定グリッドと可動要素の両方が計画に含まれています。x文字は壁を表し、スペース文字は空のスペースを表し、感嘆符は固定された動かない溶岩タイルを表します。

@はプレイヤーの開始位置を定義します。すべてのoはコインであり、等号(=)は水平に動き回る溶岩のブロックを表します。これらの位置のグリッドには空のスペースが含まれ、別のデータ構造を使用してそのような可動要素の位置を追跡することに注意してください。

垂直に動く塊にはパイプ文字(|)、そして滴り落ちる溶岩(上下に動くが、跳ね返らず、床に当たると開始位置に戻る溶岩)にはvという2種類の可動溶岩をサポートします。

ゲーム全体は、プレイヤーがクリアしなければならない複数のレベルで構成されています。すべてのコインを集めるとレベルがクリアされます。プレイヤーが溶岩に触れると、現在のレベルは開始位置にリセットされ、プレイヤーは再試行できます。

レベルの読み込み

次のコンストラクターは、レベルオブジェクトを構築します。その引数は、レベルを定義する文字列の配列である必要があります。

function Level(plan) {
  this.width = plan[0].length;
  this.height = plan.length;
  this.grid = [];
  this.actors = [];

  for (var y = 0; y < this.height; y++) {
    var line = plan[y], gridLine = [];
    for (var x = 0; x < this.width; x++) {
      var ch = line[x], fieldType = null;
      var Actor = actorChars[ch];
      if (Actor)
        this.actors.push(new Actor(new Vector(x, y), ch));
      else if (ch == "x")
        fieldType = "wall";
      else if (ch == "!")
        fieldType = "lava";
      gridLine.push(fieldType);
    }
    this.grid.push(gridLine);
  }

  this.player = this.actors.filter(function(actor) {
    return actor.type == "player";
  })[0];
  this.status = this.finishDelay = null;
}

簡潔にするために、コードは不正な入力をチェックしません。プレイヤーの開始位置やその他の必須要素を含む、適切なレベルプランが与えられていると仮定します。

レベルは、幅と高さ、そして2つの配列(グリッド用とアクタ用)を格納します。アクタは動的な要素です。グリッドは配列の配列として表され、各内部配列は水平線を表し、各正方形には、空の正方形の場合はnull、または正方形の種類を示す文字列("wall"または"lava")が含まれます。

アクタ配列は、レベル内の動的要素の現在の位置と状態を追跡するオブジェクトを保持します。これらのオブジェクトはそれぞれ、位置(左上隅の座標)を与えるposプロパティ、サイズを与えるsizeプロパティ、要素を識別する文字列("lava""coin"、または"player")を保持するtypeプロパティを持つ必要があります。

グリッドを構築した後、filterメソッドを使用してプレイヤーのアクターオブジェクトを見つけ、レベルのプロパティに格納します。statusプロパティは、プレイヤーが勝ったか負けたかを追跡します。これが発生すると、finishDelayを使用して、シンプルなアニメーションを表示するために短い時間レベルをアクティブな状態に保ちます。(すぐにレベルをリセットまたは進めると、安っぽく見えます。)このメソッドを使用して、レベルが終了したかどうかを確認できます。

Level.prototype.isFinished = function() {
  return this.status != null && this.finishDelay < 0;
};

アクタ

アクタの位置とサイズを格納するために、x座標とy座標をオブジェクトにグループ化する信頼できるVector型に戻ります。

function Vector(x, y) {
  this.x = x; this.y = y;
}
Vector.prototype.plus = function(other) {
  return new Vector(this.x + other.x, this.y + other.y);
};
Vector.prototype.times = function(factor) {
  return new Vector(this.x * factor, this.y * factor);
};

timesメソッドは、ベクトルを指定された量だけスケーリングします。これは、ある時間間隔に速度ベクトルを掛け算して、その時間内に移動した距離を取得する必要がある場合に役立ちます。

前のセクションでは、actorCharsオブジェクトはLevelコンストラクターによって、文字とコンストラクター関数を関連付けるために使用されました。オブジェクトは次のようになります。

var actorChars = {
  "@": Player,
  "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};

3つの文字がLavaにマップされます。Levelコンストラクターは、アクタのソース文字をコンストラクターの第2引数として渡し、Lavaコンストラクターはその文字を使用して動作(水平方向の跳ね返り、垂直方向の跳ね返り、または滴り落ち)を調整します。

プレイヤータイプは、次のコンストラクターで構築されます。これは、現在の速度を格納するspeedプロパティを持ち、これは運動量と重力をシミュレートするのに役立ちます。

function Player(pos) {
  this.pos = pos.plus(new Vector(0, -0.5));
  this.size = new Vector(0.8, 1.5);
  this.speed = new Vector(0, 0);
}
Player.prototype.type = "player";

プレイヤーは1.5マス分の高さなので、初期位置は@文字が表示された位置よりも0.5マス上に設定されています。これにより、底辺は表示されたマスの底辺と揃います。

動的なLavaオブジェクトを構築する際には、基づいている文字に応じてオブジェクトを異なる方法で初期化する必要があります。動的な溶岩は、障害物に当たるまで、その速度で移動します。その時点で、repeatPosプロパティがある場合、開始位置に戻ります(滴り落ちる)。そうでない場合は、速度を反転して反対方向に移動し続けます(跳ね返る)。コンストラクターは必要なプロパティを設定するだけです。実際の移動を行うメソッドは後で記述します。

function Lava(pos, ch) {
  this.pos = pos;
  this.size = new Vector(1, 1);
  if (ch == "=") {
    this.speed = new Vector(2, 0);
  } else if (ch == "|") {
    this.speed = new Vector(0, 2);
  } else if (ch == "v") {
    this.speed = new Vector(0, 3);
    this.repeatPos = pos;
  }
}
Lava.prototype.type = "lava";

Coinアクタはシンプルです。ほとんどはただその場所に留まります。しかし、ゲームを少し盛り上げるために、「揺れ」、わずかな上下の動きを与えています。これを追跡するために、コインオブジェクトは基本位置と、バウンスモーションの位相を追跡するwobbleプロパティを格納します。これらは合わせてコインの実際の位置(posプロパティに格納)を決定します。

function Coin(pos) {
  this.basePos = this.pos = pos.plus(new Vector(0.2, 0.1));
  this.size = new Vector(0.6, 0.6);
  this.wobble = Math.random() * Math.PI * 2;
}
Coin.prototype.type = "coin";

第13章では、Math.sinが円の点のy座標を与えることを学びました。円に沿って移動するにつれて、その座標は滑らかな波形で前後に移動します。そのため、サイン関数は波状の動きをモデル化するために役立ちます。

全てのコインが同期的(同期して)に上下に動く状況を避けるため、各コインの開始フェーズはランダム化されます。Math.sinの波の *フェーズ*、つまりそれが生成する波の幅は2πです。Math.randomによって返される値にその数を掛けて、コインに波上のランダムな開始位置を与えます。

これで、レベルの状態を表すために必要な全ての部品を作成しました。

var simpleLevel = new Level(simpleLevelPlan);
console.log(simpleLevel.width, "by", simpleLevel.height);
// → 22 by 9

次の課題は、そのようなレベルを画面に表示し、その内部で時間と動きをモデル化することです。

カプセル化の負担

この章のコードの大部分は、2つの理由からカプセル化を気にしません。第一に、カプセル化には余分な労力がかかります。プログラムを大きくし、追加の概念とインターフェースの導入が必要になります。読者の目がガラス細工になる前に、提示できるコードには限りがあるので、プログラムを小さく保つ努力をしました。

第二に、このゲームの様々な要素は非常に密接に関連しているため、いずれかの要素の挙動が変化した場合、他の要素が同じままでいられる可能性は低いでしょう。要素間のインターフェースは、ゲームの動作に関する多くの仮定をエンコードすることになります。これにより、インターフェースの有効性が大幅に低下します。システムの一部を変更するたびに、そのインターフェースが新しい状況を網羅していないため、他の部分への影響を依然として心配する必要があります。

システム内の一部の *切断ポイント* は、厳格なインターフェースによる分離に適していますが、そうでないものもあります。適切な境界ではないものをカプセル化しようとすることは、多くのエネルギーを無駄にする確実な方法です。この間違いを犯している場合、通常、インターフェースが不自然に大きく詳細になり、プログラムが進化するにつれて頻繁に変更する必要があることに気付くでしょう。

この章で *カプセル化する* ものは1つあり、それは描画サブシステムです。その理由は、次の章で、同じゲームを異なる方法で表示するためです。インターフェースの背後に描画を配置することで、同じゲームプログラムをそこにロードし、新しい表示モジュールを簡単にプラグインできます。

描画

描画コードのカプセル化は、特定のレベルを表示する *display* オブジェクトを定義することによって行われます。この章で定義する表示タイプは、レベルを表示するために単純なDOM要素を使用するため、DOMDisplayと呼ばれます。

ゲームを構成する要素の実際の色やその他の固定プロパティを設定するために、スタイルシートを使用します。それらの要素のstyleプロパティに直接代入することも可能ですが、それによりプログラムがより冗長になります。

次のヘルパー関数は、要素を作成してクラスを与える簡単な方法を提供します。

function elt(name, className) {
  var elt = document.createElement(name);
  if (className) elt.className = className;
  return elt;
}

表示は、自身を追加する親要素とレベルオブジェクトを与えることによって作成されます。

function DOMDisplay(parent, level) {
  this.wrap = parent.appendChild(elt("div", "game"));
  this.level = level;

  this.wrap.appendChild(this.drawBackground());
  this.actorLayer = null;
  this.drawFrame();
}

appendChildが追加された要素を返すという事実を使用して、ラッパー要素を作成し、それを1つのステートメントでwrapプロパティに格納しました。

決して変化しないレベルの背景は一度だけ描画されます。アクターは、表示が更新されるたびに再描画されます。actorLayerプロパティは、drawFrameによってアクターを保持する要素を追跡するために使用され、それらを簡単に削除して置き換えることができます。

座標とサイズは、グリッドサイズを基準とした単位で追跡されます。サイズまたは距離が1は、1グリッド単位を意味します。ピクセルサイズを設定する際には、これらの座標を拡大する必要があります。ゲーム内のすべてが1平方あたり1ピクセルではばかばかしく小さくなります。scale変数は、画面上の1単位が占めるピクセル数を示します。

var scale = 20;

DOMDisplay.prototype.drawBackground = function() {
  var table = elt("table", "background");
  table.style.width = this.level.width * scale + "px";
  this.level.grid.forEach(function(row) {
    var rowElt = table.appendChild(elt("tr"));
    rowElt.style.height = scale + "px";
    row.forEach(function(type) {
      rowElt.appendChild(elt("td", type));
    });
  });
  return table;
};

前述のように、背景は<table>要素として描画されます。これは、レベルのgridプロパティの構造とよく対応しています。グリッドの各行はテーブル行(<tr>要素)に変換されます。グリッド内の文字列は、テーブルセル(<td>)要素のクラス名として使用されます。次のCSSは、結果として得られるテーブルを目的の背景のように見せるのに役立ちます。

.background    { background: rgb(52, 166, 251);
                 table-layout: fixed;
                 border-spacing: 0;              }
.background td { padding: 0;                     }
.lava          { background: rgb(255, 100, 100); }
.wall          { background: white;              }

これらのいくつか(table-layoutborder-spacingpadding)は、不要なデフォルトの動作を抑えるために単純に使用されます。テーブルのレイアウトがセルの内容に依存することは望んでおらず、テーブルセル間のスペースやその内部のパディングも望んでいません。

backgroundルールは背景色を設定します。CSSでは、色を単語(white)とrgb(R, G, B)のような形式で指定できます。ここで、色の赤、緑、青のコンポーネントは、0〜255の3つの数字に分割されます。したがって、rgb(52, 166, 251)では、赤のコンポーネントは52、緑は166、青は251です。青のコンポーネントが最大であるため、結果の色は青みがかったものになります。.lavaルールでは、最初の数字(赤)が最大であることがわかります。

各アクターは、そのためのDOM要素を作成し、アクターのプロパティに基づいてその要素の位置とサイズを設定することによって描画されます。ゲーム単位からピクセル単位に変換するには、値にscaleを掛ける必要があります。

DOMDisplay.prototype.drawActors = function() {
  var wrap = elt("div");
  this.level.actors.forEach(function(actor) {
    var rect = wrap.appendChild(elt("div",
                                    "actor " + actor.type));
    rect.style.width = actor.size.x * scale + "px";
    rect.style.height = actor.size.y * scale + "px";
    rect.style.left = actor.pos.x * scale + "px";
    rect.style.top = actor.pos.y * scale + "px";
  });
  return wrap;
};

要素に複数のクラスを与えるには、クラス名をスペースで区切ります。次に示すCSSコードでは、actorクラスはアクターに絶対位置を与えます。それらのタイプ名は、色を与えるための追加のクラスとして使用されます。前に定義した溶岩グリッド正方形に使用したクラスを再利用するため、lavaクラスを再度定義する必要はありません。

.actor  { position: absolute;            }
.coin   { background: rgb(241, 229, 89); }
.player { background: rgb(64, 64, 64);   }

drawFrameメソッドは、表示を更新する際に、まず古いアクターグラフィック(存在する場合)を削除し、次に新しい位置に再描画します。アクターのDOM要素を再利用しようとするのは魅力的かもしれませんが、それを機能させるには、表示コードとシミュレーションコードの間で多くの追加の情報の流れが必要になります。アクターとDOM要素を関連付ける必要があり、描画コードはアクターが消滅したときに要素を削除する必要があります。通常、ゲームには少数のactorしか存在しないため、すべてを再描画しても高価ではありません。

DOMDisplay.prototype.drawFrame = function() {
  if (this.actorLayer)
    this.wrap.removeChild(this.actorLayer);
  this.actorLayer = this.wrap.appendChild(this.drawActors());
  this.wrap.className = "game " + (this.level.status || "");
  this.scrollPlayerIntoView();
};

レベルの現在の状態をラッパーにクラス名として追加することで、ゲームに勝つか負けたときにプレイヤーアクターのスタイルをわずかに変えることができます。これは、特定のクラスを持つ祖先要素がある場合にのみ有効になるCSSルールを追加することによって行われます。

.lost .player {
  background: rgb(160, 64, 64);
}
.won .player {
  box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}

溶岩に触れると、プレイヤーの色は濃い赤に変わり、焦げ付きを示唆します。最後のコインが収集されると、左上に1つ、右上に1つのぼやけた白いボックスシャドウを使用して、白いハロー効果を作成します。

レベルが常にビューポートに収まるとは仮定できません。そのため、scrollPlayerIntoView呼び出しが必要です。これは、レベルがビューポートの外側に突き出ている場合に、ビューポートをスクロールしてプレイヤーがその中心付近にあることを確認します。次のCSSは、ゲームのラッピングDOM要素に最大サイズを与え、要素のボックスから突き出ているものは表示されないようにします。また、内部のアクターがレベルの左上隅を基準にして配置されるように、外部要素に相対的な位置を与えます。

.game {
  overflow: hidden;
  max-width: 600px;
  max-height: 450px;
  position: relative;
}

scrollPlayerIntoViewメソッドでは、プレイヤーの位置を見つけて、ラッピング要素のスクロール位置を更新します。プレイヤーが端に近すぎる場合、その要素のscrollLeftプロパティとscrollTopプロパティを操作してスクロール位置を変更します。

DOMDisplay.prototype.scrollPlayerIntoView = function() {
  var width = this.wrap.clientWidth;
  var height = this.wrap.clientHeight;
  var margin = width / 3;

  // The viewport
  var left = this.wrap.scrollLeft, right = left + width;
  var top = this.wrap.scrollTop, bottom = top + height;

  var player = this.level.player;
  var center = player.pos.plus(player.size.times(0.5))
                 .times(scale);

  if (center.x < left + margin)
    this.wrap.scrollLeft = center.x - margin;
  else if (center.x > right - margin)
    this.wrap.scrollLeft = center.x + margin - width;
  if (center.y < top + margin)
    this.wrap.scrollTop = center.y - margin;
  else if (center.y > bottom - margin)
    this.wrap.scrollTop = center.y + margin - height;
};

プレイヤーの中心を見つける方法は、Vectorタイプのメソッドによってオブジェクトの計算をどのように読みやすく記述できるかを示しています。アクターの中心を見つけるには、その位置(左上隅)とそのサイズの半分を加算します。これはレベル座標の中心ですが、ピクセル座標が必要なため、結果のベクトルに表示スケールを掛けます。

次に、一連のチェックによって、プレイヤーの位置が許可される範囲外ではないことが確認されます。場合によっては、0より小さいか要素のスクロール可能な領域を超えるナンセンスなスクロール座標が設定されることに注意してください。これは問題ありません。DOMはそれらを健全な値に制限します。scrollLeftを-10に設定すると、0になります。

プレイヤーを常にビューポートの中心にスクロールしようとする方が少し簡単だったでしょう。しかし、これはかなりぎこちない効果を生み出します。ジャンプしているとき、ビューは常に上下にシフトします。スクロールすることなく移動できる画面の中央に「ニュートラル」エリアがある方が快適です。

最後に、ゲームが次のレベルに移動するか、レベルをリセットするときに使用する、表示されたレベルをクリアする方法が必要です。

DOMDisplay.prototype.clear = function() {
  this.wrap.parentNode.removeChild(this.wrap);
};

これで、小さなレベルを表示できるようになりました。

<link rel="stylesheet" href="css/game.css">

<script>
  var simpleLevel = new Level(simpleLevelPlan);
  var display = new DOMDisplay(document.body, simpleLevel);
</script>

<link>タグは、rel="stylesheet"と共に使用されると、CSSファイルをページにロードする方法です。game.cssファイルには、ゲームに必要なスタイルが含まれています。

動きと衝突

これで、ゲームで最も興味深い側面である動きを追加できるようになりました。この種のほとんどのゲームで使用される基本的なアプローチは、時間を小さなステップに分割し、各ステップで、速度(秒あたりの移動距離)に時間ステップのサイズ(秒単位)を掛けた距離だけアクターを移動することです。

それは簡単です。難しいのは、要素間の相互作用に対処することです。プレイヤーが壁や床に当たったとき、単にそれを通り抜けるわけにはいきません。ゲームは、特定の動きによってオブジェクトが別のオブジェクトに衝突したことを認識し、それに応じて反応しなければなりません。壁の場合は、動きを停止させる必要があります。コインの場合は、コインを回収する必要があります。その他も同様です。

これを一般的なケースで解決することは、大きな課題です。通常は物理エンジンと呼ばれるライブラリがあり、2次元または3次元の物理オブジェクト間の相互作用をシミュレートします。この章では、より控えめなアプローチを取り、長方形のオブジェクト間の衝突のみを、かなり単純な方法で処理します。

プレイヤーまたは溶岩ブロックを動かす前に、その動きによって背景の非空の部分に入り込むかどうかをテストします。入り込む場合は、単に動きを完全にキャンセルします。このような衝突への対応は、アクタの種類によって異なります。プレイヤーは停止しますが、溶岩ブロックは跳ね返ります。

このアプローチでは、オブジェクトが実際に接触する前に動きが停止するため、タイムステップをかなり小さくする必要があります。タイムステップ(したがってモーションステップ)が大きすぎると、プレイヤーは地面から目に見える距離だけ浮いていることになります。別の方法として、より複雑ですがおそらくより良い方法として、正確な衝突点を検出し、そこに移動する方法があります。ここでは簡単なアプローチを取り、小さなステップでアニメーションが進むようにすることで、その問題を隠します。

このメソッドは、長方形(位置とサイズで指定)が背景グリッド上の非空の空間と重なっているかどうかを判定します。

Level.prototype.obstacleAt = function(pos, size) {
  var xStart = Math.floor(pos.x);
  var xEnd = Math.ceil(pos.x + size.x);
  var yStart = Math.floor(pos.y);
  var yEnd = Math.ceil(pos.y + size.y);

  if (xStart < 0 || xEnd > this.width || yStart < 0)
    return "wall";
  if (yEnd > this.height)
    return "lava";
  for (var y = yStart; y < yEnd; y++) {
    for (var x = xStart; x < xEnd; x++) {
      var fieldType = this.grid[y][x];
      if (fieldType) return fieldType;
    }
  }
};

このメソッドは、オブジェクトの座標に`Math.floor`と`Math.ceil`を使用して、オブジェクトが重なっているグリッド正方形の集合を計算します。グリッド正方形のサイズは1×1単位であることを覚えておいてください。ボックスの辺を上下に丸めることで、ボックスが接触する背景正方形の範囲が得られます。

Finding collisions on a grid

オブジェクトがレベルからはみ出している場合、辺と上部には常に`"wall"`を、底部には`"lava"`を返します。これにより、プレイヤーがワールドから落下したときに死亡することが保証されます。オブジェクトがグリッド内に完全に含まれている場合、座標を丸めることによって見つかったグリッド正方形のブロックをループ処理し、最初に発見した非空の正方形の内容を返します。

プレイヤーと他の動的アクタ(コイン、移動する溶岩)間の衝突は、プレイヤーが移動した後に処理されます。動きによってプレイヤーが別のアクタに入った場合、コインの回収や死亡などの適切な効果がアクティブになります。

このメソッドは、アクタの配列をスキャンし、引数として与えられたアクタと重なっているアクタを探します。

Level.prototype.actorAt = function(actor) {
  for (var i = 0; i < this.actors.length; i++) {
    var other = this.actors[i];
    if (other != actor &&
        actor.pos.x + actor.size.x > other.pos.x &&
        actor.pos.x < other.pos.x + other.size.x &&
        actor.pos.y + actor.size.y > other.pos.y &&
        actor.pos.y < other.pos.y + other.size.y)
      return other;
  }
};

アクタとアクション

`Level`型の`animate`メソッドは、レベル内のすべてのアクタに移動の機会を与えます。その`step`引数は、秒単位のタイムステップです。`keys`オブジェクトには、プレイヤーが押した矢印キーに関する情報が含まれています。

var maxStep = 0.05;

Level.prototype.animate = function(step, keys) {
  if (this.status != null)
    this.finishDelay -= step;

  while (step > 0) {
    var thisStep = Math.min(step, maxStep);
    this.actors.forEach(function(actor) {
      actor.act(thisStep, this, keys);
    }, this);
    step -= thisStep;
  }
};

レベルの`status`プロパティにnull以外の値がある場合(プレイヤーが勝利または敗北した場合)、`finishDelay`プロパティをカウントダウンする必要があります。これは、勝利または敗北が発生した時点と、レベルの表示を停止する時点の間の時間を追跡します。

`while`ループは、アニメーションを行うタイムステップを適切な小さな部分に分割します。これにより、`maxStep`より大きいステップが実行されないことが保証されます。たとえば、0.12秒の`step`は、0.05秒のステップ2つと0.02秒のステップ1つに分割されます。

アクタオブジェクトには`act`メソッドがあり、タイムステップ、レベルオブジェクト、`keys`オブジェクトを引数として受け取ります。以下は、`keys`オブジェクトを無視する`Lava`アクタ型用の`act`メソッドの例です。

Lava.prototype.act = function(step, level) {
  var newPos = this.pos.plus(this.speed.times(step));
  if (!level.obstacleAt(newPos, this.size))
    this.pos = newPos;
  else if (this.repeatPos)
    this.pos = this.repeatPos;
  else
    this.speed = this.speed.times(-1);
};

これは、タイムステップとその現在の速度の積を古い位置に追加することによって新しい位置を計算します。その新しい位置を妨げる障害物がない場合、そこに移動します。障害物がある場合、溶岩ブロックの種類によって動作が異なります。垂れる溶岩には`repeatPos`プロパティがあり、何か物に当たるとそこにジャンプバックします。跳ねる溶岩は、単に速度を反転(-1を掛ける)して、反対方向に動き始めます。

コインは、`act`メソッドを使用して揺れます。それらは、自分の正方形内で単に揺れているだけであり、プレイヤーとの衝突はプレイヤーの`act`メソッドによって処理されるため、衝突を無視します。

var wobbleSpeed = 8, wobbleDist = 0.07;

Coin.prototype.act = function(step) {
  this.wobble += step * wobbleSpeed;
  var wobblePos = Math.sin(this.wobble) * wobbleDist;
  this.pos = this.basePos.plus(new Vector(0, wobblePos));
};

`wobble`プロパティは時間を追跡するために更新され、次に`Math.sin`の引数として使用されて波が作成され、これが新しい位置の計算に使用されます。

残りはプレイヤー自身です。床に当たることで水平方向の動きが妨げられるべきではなく、壁に当たることで落下またはジャンプの動きが停止するべきではないため、プレイヤーの動きは軸ごとに個別に処理されます。このメソッドは水平方向の部分を実装します。

var playerXSpeed = 7;

Player.prototype.moveX = function(step, level, keys) {
  this.speed.x = 0;
  if (keys.left) this.speed.x -= playerXSpeed;
  if (keys.right) this.speed.x += playerXSpeed;

  var motion = new Vector(this.speed.x * step, 0);
  var newPos = this.pos.plus(motion);
  var obstacle = level.obstacleAt(newPos, this.size);
  if (obstacle)
    level.playerTouched(obstacle);
  else
    this.pos = newPos;
};

水平方向の動きは、左右の矢印キーの状態に基づいて計算されます。動きによってプレイヤーが何か物に当たると、溶岩での死亡やコインの回収などを処理するレベルの`playerTouched`メソッドが呼び出されます。そうでない場合、オブジェクトは自分の位置を更新します。

垂直方向の動きは同様の方法で動作しますが、ジャンプと重力をシミュレートする必要があります。

var gravity = 30;
var jumpSpeed = 17;

Player.prototype.moveY = function(step, level, keys) {
  this.speed.y += step * gravity;
  var motion = new Vector(0, this.speed.y * step);
  var newPos = this.pos.plus(motion);
  var obstacle = level.obstacleAt(newPos, this.size);
  if (obstacle) {
    level.playerTouched(obstacle);
    if (keys.up && this.speed.y > 0)
      this.speed.y = -jumpSpeed;
    else
      this.speed.y = 0;
  } else {
    this.pos = newPos;
  }
};

メソッドの開始時に、重力を考慮してプレイヤーは垂直方向に加速されます。このゲームの重力、ジャンプ速度、そしてほとんどすべての定数は、試行錯誤によって設定されました。気に入った組み合わせが見つかるまで、さまざまな値をテストしました。

次に、再び障害物をチェックします。障害物に当たった場合、2つの結果が考えられます。上矢印キーが押されていて、下に移動している場合(つまり、当たった物が自分の下にある場合)、速度は比較的大きな負の値に設定されます。これにより、プレイヤーはジャンプします。そうでない場合、単に何か物にぶつかっただけであり、速度はゼロにリセットされます。

実際の`act`メソッドは次のようになります。

Player.prototype.act = function(step, level, keys) {
  this.moveX(step, level, keys);
  this.moveY(step, level, keys);

  var otherActor = level.actorAt(this);
  if (otherActor)
    level.playerTouched(otherActor.type, otherActor);

  // Losing animation
  if (level.status == "lost") {
    this.pos.y += step;
    this.size.y -= step;
  }
};

移動後、メソッドはプレイヤーと衝突している他のアクタをチェックし、見つけた場合に再び`playerTouched`を呼び出します。今回は、他のアクタがコインである場合、`playerTouched`はどのコインが回収されているのかを知る必要があるため、アクタオブジェクトを2番目の引数として渡します。

最後に、プレイヤーが死亡した場合(溶岩に触れた場合)、プレイヤーオブジェクトの高さを減らすことで「縮む」または「沈む」アニメーションを設定します。

そして、プレイヤーと他のオブジェクト間の衝突を処理するメソッドを次に示します。

Level.prototype.playerTouched = function(type, actor) {
  if (type == "lava" && this.status == null) {
    this.status = "lost";
    this.finishDelay = 1;
  } else if (type == "coin") {
    this.actors = this.actors.filter(function(other) {
      return other != actor;
    });
    if (!this.actors.some(function(actor) {
      return actor.type == "coin";
    })) {
      this.status = "won";
      this.finishDelay = 1;
    }
  }
};

溶岩に触れると、ゲームの状態は`"lost"`に設定されます。コインに触れると、そのコインはアクタの配列から削除され、それが最後のコインだった場合は、ゲームの状態は`"won"`に設定されます。

これにより、実際にアニメーション化できるレベルが得られます。残っているのは、アニメーションを駆動するコードだけです。

キーの追跡

このようなゲームでは、キーをキーを押すたびに1回だけ有効にすることは望ましくありません。むしろ、キーが押されている限り、その効果(プレイヤーフィギュアの移動)が継続的に発生することを望みます。

左右と上の矢印キーの現在の状態を保存するキーハンドラーを設定する必要があります。また、それらのキーに対して`preventDefault`を呼び出して、ページのスクロールが行われないようにする必要があります。

キーコードをプロパティ名として、キー名を値として持つオブジェクトが与えられると、これらのキーの現在の状態を追跡するオブジェクトを返す次の関数です。これは`keydown`イベントと`keyup`イベントのイベントハンドラーを登録し、イベント内のキーコードが追跡しているコードのセットに存在する場合、オブジェクトを更新します。

var arrowCodes = {37: "left", 38: "up", 39: "right"};

function trackKeys(codes) {
  var pressed = Object.create(null);
  function handler(event) {
    if (codes.hasOwnProperty(event.keyCode)) {
      var down = event.type == "keydown";
      pressed[codes[event.keyCode]] = down;
      event.preventDefault();
    }
  }
  addEventListener("keydown", handler);
  addEventListener("keyup", handler);
  return pressed;
}

同じハンドラー関数が両方のイベント型に使用されていることに注意してください。キーの状態をtrue(`keydown`)またはfalse(`keyup`)に更新する必要があるかどうかを判断するために、イベントオブジェクトの`type`プロパティを確認します。

ゲームの実行

第13章で見た`requestAnimationFrame`関数は、ゲームをアニメーション化するための良い方法です。しかし、そのインターフェースはかなりプリミティブです。これを使用するには、関数が前回呼び出された時間を追跡し、各フレーム後に`requestAnimationFrame`を再び呼び出す必要があります。

これらの退屈な部分を便利なインターフェースでラップし、時間差を引数として期待する関数と単一のフレームを描画する関数を`runAnimation`に渡して単に呼び出すことができるヘルパー関数を定義しましょう。フレーム関数が`false`を返す場合、アニメーションは停止します。

function runAnimation(frameFunc) {
  var lastTime = null;
  function frame(time) {
    var stop = false;
    if (lastTime != null) {
      var timeStep = Math.min(time - lastTime, 100) / 1000;
      stop = frameFunc(timeStep) === false;
    }
    lastTime = time;
    if (!stop)
      requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}

最大フレームステップを100ミリ秒(1/10秒)に設定しました。当ページを表示しているブラウザタブまたはウィンドウが非表示になると、requestAnimationFrameの呼び出しは、タブまたはウィンドウが表示されるまで中断されます。この場合、lastTimetimeの差は、ページが非表示だった全体の時間になります。その時間分だけゲームを進めると、ぎこちなく見えたり、多くの処理が必要になったりする可能性があります(animateメソッドにおける時間分割を思い出してください)。

この関数では、時間ステップをミリ秒ではなく秒に変換します。秒の方が考えやすい単位です。

runLevel関数は、Levelオブジェクト、ディスプレイのコンストラクタ、およびオプションで関数を引数として受け取ります。この関数はレベルを(document.bodyに)表示し、ユーザーがプレイできるようにします。レベルが終了(敗北または勝利)すると、runLevelはディスプレイをクリアし、アニメーションを停止し、andThen関数が指定されている場合は、レベルのステータスと共にその関数を呼び出します。

var arrows = trackKeys(arrowCodes);

function runLevel(level, Display, andThen) {
  var display = new Display(document.body, level);
  runAnimation(function(step) {
    level.animate(step, arrows);
    display.drawFrame(step);
    if (level.isFinished()) {
      display.clear();
      if (andThen)
        andThen(level.status);
      return false;
    }
  });
}

ゲームはレベルのシーケンスです。プレイヤーが死亡するたびに、現在のレベルが再開されます。レベルが完了すると、次のレベルに進みます。これは、レベルプラン(文字列の配列)の配列とディスプレイコンストラクタを引数として取る以下の関数で表現できます。

function runGame(plans, Display) {
  function startLevel(n) {
    runLevel(new Level(plans[n]), Display, function(status) {
      if (status == "lost")
        startLevel(n);
      else if (n < plans.length - 1)
        startLevel(n + 1);
      else
        console.log("You win!");
    });
  }
  startLevel(0);
}

これらの関数は、独特のプログラミングスタイルを示しています。runAnimationrunLevelはどちらも高階関数ですが、第5章で見たスタイルではありません。関数引数は、将来のある時点で発生する処理を調整するために使用され、どちらの関数も有用な値を返しません。これらの関数の役割は、ある意味、アクションのスケジュールを設定することです。これらのアクションを関数でラップすることで、値として保存し、適切なタイミングで呼び出すことができます。

このプログラミングスタイルは通常、非同期プログラミングと呼ばれます。イベント処理もこのスタイルの一例であり、第17章のネットワークリクエストや第20章の一般的な入出力など、任意の時間がかかるタスクを扱う際に、さらに詳しく見ていきます。

GAME_LEVELS変数には、利用可能なレベルプランのセットがあります。このページはそれらをrunGameに渡し、実際のゲームを開始します。

<link rel="stylesheet" href="css/game.css">

<body>
  <script>
    runGame(GAME_LEVELS, DOMDisplay);
  </script>
</body>

クリアを目指してみてください。私はそれらを構築する際にかなりの楽しみを得ました。

演習

ゲームオーバー

プラットフォームゲームでは、プレイヤーがライフを限られた数で開始し、死亡するたびに1ライフを減らすのが一般的です。プレイヤーのライフがなくなると、ゲームは最初から再開されます。

ライフを実装するようにrunGameを調整してください。プレイヤーは3ライフで開始します。

<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // The old runGame function. Modify it...
  function runGame(plans, Display) {
    function startLevel(n) {
      runLevel(new Level(plans[n]), Display, function(status) {
        if (status == "lost")
          startLevel(n);
        else if (n < plans.length - 1)
          startLevel(n + 1);
        else
          console.log("You win!");
      });
    }
    startLevel(0);
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

最も分かりやすい解決策は、livesを変数としてrunGame内に配置し、startLevelクロージャから見えるようにすることです。

別の方法として、関数の引数にシステム全体の状態を格納するという、関数の残りの部分の精神にうまく合う方法があります。これは、startLevelにライフ数を示す第2のパラメータを追加することです。システム全体の状態が関数の引数に格納されている場合、その関数を呼び出すことで、新しい状態への移行をエレガントに行うことができます。

いずれの場合も、レベルが失敗した場合は、2つの可能な状態遷移があります。それが最後のライフだった場合、開始時のライフ数でレベル0に戻ります。そうでない場合、残りのライフ数が1つ少ない状態で現在のレベルを繰り返します。

ゲームの一時停止

Escキーを押してゲームを一時停止(中断)および再開できるようにします。

これは、runLevel関数を変更して別のキーボードイベントハンドラを使用し、Escキーが押されたときにアニメーションを中断または再開することで実現できます。

一見、runAnimationインターフェースはこの目的には適していないように見えるかもしれませんが、runLevelがそれを呼び出す方法を再構成すれば可能です。

それが機能したら、別のことを試すことができます。これまでキーボードイベントハンドラを登録してきた方法は、やや問題があります。arrowsオブジェクトは現在グローバル変数であり、ゲームが実行されていない場合でも、そのイベントハンドラは保持されます。これは、システムからリークしていると考えることができます。trackKeysを拡張して、ハンドラを登録解除する方法を提供し、runLevelを変更して、開始時にハンドラを登録し、終了時に登録解除するようにします。

<link rel="stylesheet" href="css/game.css">

<body>
<script>
  // The old runLevel function. Modify this...
  function runLevel(level, Display, andThen) {
    var display = new Display(document.body, level);
    runAnimation(function(step) {
      level.animate(step, arrows);
      display.drawFrame(step);
      if (level.isFinished()) {
        display.clear();
        if (andThen)
          andThen(level.status);
        return false;
      }
    });
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

アニメーションは、runAnimationに渡された関数からfalseを返すことで中断できます。runAnimationを再度呼び出すことで続行できます。

アニメーションを中断する必要があることをrunAnimationに渡された関数に伝えるには、イベントハンドラと関数の両方がアクセスできる変数を使用できます。

trackKeysによって登録されたハンドラを登録解除する方法を見つける際には、ハンドラを正常に削除するには、addEventListenerに渡されたものとまったく同じ関数値をremoveEventListenerに渡す必要があることに注意してください。そのため、trackKeysで作成されたhandler関数値は、ハンドラを登録解除するコードで使用可能である必要があります。

trackKeysによって返されるオブジェクトに、その関数値、または登録解除を直接処理するメソッドのいずれかを含むプロパティを追加できます。