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

あらゆる現実とはゲームである。

Iain Banks, ゲームのプレイヤー
Illustration showing a computer game character jumping over lava in a two dimensional world

多くのオタク気質の子供たちと同じように、私自身のコンピュータへの最初の関心の多くは、コンピュータゲームに関係していました。私は、操作することができ、ある種のストーリー(何となく)が展開される小さなシミュレートされた世界に引き込まれました――それは、私が自分の想像力をそこに投影する方法によるものだったと思います。実際の可能性というよりも。

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

この章では、小さなプラットフォームゲームの実装手順を説明します。プラットフォームゲーム(または「ジャンプアンドラン」ゲーム)は、プレイヤーが通常は二次元で横から見た世界を通してキャラクターを移動させ、物の上を飛び越えたり、物の上にジャンプしたりすることを期待するゲームです。

ゲーム

私たちのゲームは、Thomas PalefによるDark Blueを大まかにベースにします。このゲームを選んだ理由は、面白くてミニマルであり、あまり多くのコードを書かずに構築できるからです。見た目は次のようになります。

Screenshot of the 'Dark Blue' game, showing a world made out of colored boxes. There's a black box representing the player, standing on lines of white against a blue background. Small yellow coins float in the air, and some parts of the background are red, representing lava.

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

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

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

技術

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

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

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

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

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

レベル

人間が読みやすく、人間が編集できる方法でレベルを指定したいと考えています。すべてがグリッド上で開始しても問題ないため、各文字が要素(背景グリッドの一部または移動要素)を表す大きな文字列を使用できます。

小さなレベルの計画は次のようになります。

let simpleLevelPlan = `
......................
..#................#..
..#..............=.#..
..#.........o.o....#..
..#.@......#####...#..
..#####............#..
......#++++++++++++#..
......##############..
......................`;

ピリオドは空のスペース、ハッシュ(#)文字は壁、プラス記号は溶岩です。プレイヤーの開始位置はアットマーク(@)です。すべてのO文字はコインであり、上部の等号(=)は水平方向に動く溶岩の塊です。

さらに2種類の移動する溶岩をサポートします。パイプ文字(|)は垂直に動く塊を作成し、v垂れる溶岩を示します――上下に動く溶岩で、行ったり来たりするのではなく、下に向かってのみ動き、床に当たると開始位置に戻ります。

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

レベルの読み込み

次のクラスは、レベルオブジェクトを格納します。その引数は、レベルを定義する文字列である必要があります。

class Level {
  constructor(plan) {
    let rows = plan.trim().split("\n").map(l => [...l]);
    this.height = rows.length;
    this.width = rows[0].length;
    this.startActors = [];

    this.rows = rows.map((row, y) => {
      return row.map((ch, x) => {
        let type = levelChars[ch];
        if (typeof type != "string") {
          let pos = new Vec(x, y);
          this.startActors.push(type.create(pos, ch));
          type = "empty";
        }
        return type;
      });
    });
  }
}

trimメソッドは、計画文字列の先頭と末尾の空白を削除するために使用されます。これにより、私たちの例の計画は改行で開始できるため、すべての行が互いに直接下に配置されます。残りの文字列は改行文字で分割され、各行は配列に展開され、文字の配列が生成されます。

そのため、rowsは文字の配列の配列(計画の行)を保持します。ここからレベルの幅と高さを導き出すことができます。しかし、移動要素を背景グリッドから分離する必要があります。移動要素をアクタと呼びます。これらはオブジェクトの配列に格納されます。背景は文字列の配列の配列になり、"empty""wall""lava"などのフィールドタイプを保持します。

これらの配列を作成するために、行に対して、次にそのコンテンツに対してマップします。mapは、マッピング関数に配列インデックスを第2引数として渡すことを思い出してください。これにより、特定の文字のx座標とy座標がわかります。ゲーム内の位置は、座標のペアとして格納され、左上が0,0で、各背景の正方形の高さと幅が1単位になります。

計画の文字を解釈するために、LevelコンストラクタはlevelCharsオブジェクトを使用します。このオブジェクトは、レベルの説明で使用される各文字に対して、背景タイプの場合は文字列を、アクタを作成する場合はクラスを保持します。typeがアクタクラスの場合、その静的createメソッドを使用してオブジェクトが作成され、startActorsに追加され、この背景の正方形に対して"empty"が返されます。

アクタの位置はVecオブジェクトとして格納されます。これは二次元ベクトルであり、第6章の演習で見たように、xプロパティとyプロパティを持つオブジェクトです。

ゲームの実行中に、アクタは異なる場所に移動したり、完全に消滅したりします(コインは収集されると消えます)。実行中のゲームの状態を追跡するためにStateクラスを使用します。

class State {
  constructor(level, actors, status) {
    this.level = level;
    this.actors = actors;
    this.status = status;
  }

  static start(level) {
    return new State(level, level.startActors, "playing");
  }

  get player() {
    return this.actors.find(a => a.type == "player");
  }
}

statusプロパティは、ゲームが終了すると"lost"または"won"に切り替わります。

これも永続的なデータ構造です。ゲームの状態を更新すると、新しい状態が作成され、古い状態はそのまま残ります。

アクタ

アクタオブジェクトは、ゲーム内の特定の移動要素(プレイヤー、コイン、または移動する溶岩)の現在の位置と状態を表します。すべてのアクタオブジェクトは同じインターフェースに準拠しています。このアクタを表す長方形のサイズと左上の座標を保持するsizeプロパティとposプロパティ、およびupdateメソッドがあります。

このupdateメソッドは、所定の時間ステップ後に新しい状態と位置を計算するために使用されます。アクタが実行する動作(プレイヤーの矢印キーへの反応による移動、溶岩の行ったり来たり)をシミュレートし、新しい更新されたアクタオブジェクトを返します。

typeプロパティには、アクタの種類を識別する文字列("player""coin"、または"lava")が含まれています。これはゲームを描画する際に役立ちます。アクタに対して描画される長方形の外観はそのタイプに基づいています。

アクタクラスには、Levelコンストラクタがレベル計画の文字からアクタを作成するために使用する静的createメソッドがあります。これは、文字の座標と文字自体が与えられます。これは、Lavaクラスがいくつかの異なる文字を処理するため必要です。

これは、アクターの位置やサイズなど、二次元値に使用されるVecクラスです。

class Vec {
  constructor(x, y) {
    this.x = x; this.y = y;
  }
  plus(other) {
    return new Vec(this.x + other.x, this.y + other.y);
  }
  times(factor) {
    return new Vec(this.x * factor, this.y * factor);
  }
}

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

異なる種類のアクターは、その動作が大きく異なるため、それぞれ独自のクラスを持ちます。これらのクラスを定義しましょう。それらのupdateメソッドについては後で説明します。

プレイヤー・クラスには、運動量と重力をシミュレートするために現在の速度を格納するspeedプロパティがあります。

class Player {
  constructor(pos, speed) {
    this.pos = pos;
    this.speed = speed;
  }

  get type() { return "player"; }

  static create(pos) {
    return new Player(pos.plus(new Vec(0, -0.5)),
                      new Vec(0, 0));
  }
}

Player.prototype.size = new Vec(0.8, 1.5);

プレイヤーは1.5マス分の高さがあるため、初期位置は@文字が表示された位置よりも半マス上に設定されます。これにより、プレイヤーの下端が、表示されたマスの下端と揃います。

sizeプロパティはすべてのPlayerインスタンスで同じであるため、インスタンス自体ではなくプロトタイプに格納します。typeのようなゲッターを使用することもできますが、そうするとプロパティが読み取られるたびに新しいVecオブジェクトが作成され、返されるため、無駄になります。(文字列は不変であるため、評価されるたびに再作成する必要はありません。)

Lavaアクターを構築する際には、基になる文字によってオブジェクトの初期化方法が異なります。動的な溶岩は、障害物に当たるまで現在の速度で移動します。その時点で、resetプロパティがある場合、開始位置に戻ります(滴り落ちる)。ない場合は、速度を反転して反対方向に移動します(跳ね返る)。

createメソッドは、Levelコンストラクターが渡す文字を見て、適切な溶岩アクターを作成します。

class Lava {
  constructor(pos, speed, reset) {
    this.pos = pos;
    this.speed = speed;
    this.reset = reset;
  }

  get type() { return "lava"; }

  static create(pos, ch) {
    if (ch == "=") {
      return new Lava(pos, new Vec(2, 0));
    } else if (ch == "|") {
      return new Lava(pos, new Vec(0, 2));
    } else if (ch == "v") {
      return new Lava(pos, new Vec(0, 3), pos);
    }
  }
}

Lava.prototype.size = new Vec(1, 1);

Coinアクターは比較的単純です。ほとんどは自分の場所に留まっています。しかし、ゲームを少し面白くするために、「wobble」(わずかな上下動)が与えられます。これを追跡するために、コインオブジェクトは基準位置と、バウンスモーションの位相を追跡するwobbleプロパティを格納します。これら2つを組み合わせることで、コインの実際の位置(posプロパティに格納)が決まります。

class Coin {
  constructor(pos, basePos, wobble) {
    this.pos = pos;
    this.basePos = basePos;
    this.wobble = wobble;
  }

  get type() { return "coin"; }

  static create(pos) {
    let basePos = pos.plus(new Vec(0.2, 0.1));
    return new Coin(basePos, basePos,
                    Math.random() * Math.PI * 2);
  }
}

Coin.prototype.size = new Vec(0.6, 0.6);

14章で見たように、Math.sinは円上の点のy座標を与えます。円に沿って移動すると、この座標は滑らかな波形で前後に移動するため、正弦関数を使用して波状の動きをモデル化することができます。

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

これで、プラン文字を背景グリッドタイプまたはアクタークラスにマッピングするlevelCharsオブジェクトを定義できます。

const levelChars = {
  ".": "empty", "#": "wall", "+": "lava",
  "@": Player, "o": Coin,
  "=": Lava, "|": Lava, "v": Lava
};

これで、Levelインスタンスを作成するために必要なすべての部品が揃いました。

let simpleLevel = new Level(simpleLevelPlan);
console.log(`${simpleLevel.width} by ${simpleLevel.height}`);
// → 22 by 9

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

描画

次の章では、同じゲームを異なる方法で表示します。それを可能にするために、描画ロジックをインターフェースの背後に置き、それを引数としてゲームに渡します。このようにして、異なる新しい表示モジュールで同じゲームプログラムを使用できます。

ゲーム表示オブジェクトは、指定されたレベルと状態を描画します。そのコンストラクターをゲームに渡して、置き換えできるようにします。この章で定義する表示クラスは、レベルを表示するためにDOM要素を使用するため、DOMDisplayと呼ばれます。

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

次のヘルパー関数は、要素を作成し、いくつかの属性と子ノードを与える簡潔な方法を提供します。

function elt(name, attrs, ...children) {
  let dom = document.createElement(name);
  for (let attr of Object.keys(attrs)) {
    dom.setAttribute(attr, attrs[attr]);
  }
  for (let child of children) {
    dom.appendChild(child);
  }
  return dom;
}

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

class DOMDisplay {
  constructor(parent, level) {
    this.dom = elt("div", {class: "game"}, drawGrid(level));
    this.actorLayer = null;
    parent.appendChild(this.dom);
  }

  clear() { this.dom.remove(); }
}

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

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

const scale = 20;

function drawGrid(level) {
  return elt("table", {
    class: "background",
    style: `width: ${level.width * scale}px`
  }, ...level.rows.map(row =>
    elt("tr", {style: `height: ${scale}px`},
        ...row.map(type => elt("td", {class: type})))
  ));
}

<table>要素の形式は、レベルのrowsプロパティの構造とよく対応しています。グリッドの各行はテーブル行(<tr>要素)に変換されます。グリッド内の文字列は、テーブルセル(<td>)要素のクラス名として使用されます。このコードは、スプレッド(三重ドット)演算子を使用して、子ノードの配列を個別の引数としてeltに渡します。

次の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を掛ける必要があります。

function drawActors(actors) {
  return elt("div", {}, ...actors.map(actor => {
    let rect = elt("div", {class: `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 rect;
  }));
}

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

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

syncStateメソッドは、表示に指定された状態を表示するために使用されます。まず、古いアクターのグラフィックを削除してから、新しい位置にアクターを再描画します。アクターのDOM要素を再利用しようとすると魅力的かもしれませんが、それを機能させるには、アクターとDOM要素を関連付けるための追加の帳簿管理が必要になり、アクターが消滅したときに要素を確実に削除する必要があります。ゲームには通常少数の俳優しかいないため、すべてを再描画してもコストはかかりません。

DOMDisplay.prototype.syncState = function(state) {
  if (this.actorLayer) this.actorLayer.remove();
  this.actorLayer = drawActors(state.actors);
  this.dom.appendChild(this.actorLayer);
  this.dom.className = `game ${state.status}`;
  this.scrollPlayerIntoView(state);
};

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

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

溶岩に触れた後、プレイヤーは焦げ付きを示唆する濃い赤色に変わります。最後のコインが収集されると、白くぼやけた影が2つ(左上に1つ、右上に1つ)追加され、白いハロー効果が作成されます。

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

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

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

DOMDisplay.prototype.scrollPlayerIntoView = function(state) {
  let width = this.dom.clientWidth;
  let height = this.dom.clientHeight;
  let margin = width / 3;

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

  let player = state.player;
  let center = player.pos.plus(player.size.times(0.5))
                         .times(scale);

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

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

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

プレイヤーを常にビューポートの中央にスクロールしようとすると多少単純化できますが、これは非常にぎこちない効果を生み出します。ジャンプ中に、ビューは常に上下にシフトします。画面の中央に「ニュートラル」な領域があり、スクロールすることなく移動できる方が快適です。

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

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

<script>
  let simpleLevel = new Level(simpleLevelPlan);
  let display = new DOMDisplay(document.body, simpleLevel);
  display.syncState(State.start(simpleLevel));
</script>

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

モーションと衝突

これで、モーションを追加できる段階になりました。このようなほとんどのゲームで採用されている基本的なアプローチは、時間を小さなステップに分割し、各ステップで、速度に時間ステップのサイズを掛けた値に対応する距離だけアクタを移動することです。時間は秒単位で測定するため、速度は単位/秒で表されます。

ものの移動は簡単です。難しいのは、要素間の相互作用に対処することです。プレイヤーが壁や床に当たった場合、単純にそれを突き抜けるべきではありません。ゲームは、特定のモーションによってオブジェクトが別のオブジェクトに衝突した場合にそれを認識し、適切に対応する必要があります。壁の場合、モーションを停止する必要があります。コインに当たった場合、そのコインを回収する必要があります。溶岩に触れた場合、ゲームは終了する必要があります。

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

プレイヤーや溶岩のブロックを移動する前に、そのモーションが壁の内側に入るかどうかをテストします。入る場合は、モーションを完全にキャンセルします。このような衝突への対応は、アクタの種類によって異なります。プレイヤーは停止しますが、溶岩ブロックは跳ね返ります。

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

このメソッドは、(位置とサイズで指定された)長方形が、指定されたタイプのグリッド要素に触れているかどうかを判断します。

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

  for (let y = yStart; y < yEnd; y++) {
    for (let x = xStart; x < xEnd; x++) {
      let isOutside = x < 0 || x >= this.width ||
                      y < 0 || y >= this.height;
      let here = isOutside ? "wall" : this.rows[y][x];
      if (here == type) return true;
    }
  }
  return false;
};

このメソッドは、座標にMath.floorMath.ceilを使用して、ボディがオーバーラップするグリッド正方形の集合を計算します。グリッド正方形のサイズは1×1単位であることを思い出してください。ボックスの辺を上下に丸めることで、ボックスが触れる背景正方形の範囲が得られます。

Diagram showing a grid with a black box overlaid on it. All of the grid squares that are partially covered by the block are marked.

座標を丸めることで見つかったグリッド正方形のブロックをループし、一致する正方形が見つかった場合にtrueを返します。レベル外の正方形は常に"wall"として扱われ、プレイヤーが世界から出ることができないようにし、rows配列の境界外を読み取ろうとして誤ってエラーが発生しないようにします。

状態のupdateメソッドは、touchesを使用して、プレイヤーが溶岩に触れているかどうかを判断します。

State.prototype.update = function(time, keys) {
  let actors = this.actors
    .map(actor => actor.update(time, this, keys));
  let newState = new State(this.level, actors, this.status);

  if (newState.status != "playing") return newState;

  let player = newState.player;
  if (this.level.touches(player.pos, player.size, "lava")) {
    return new State(this.level, actors, "lost");
  }

  for (let actor of actors) {
    if (actor != player && overlap(actor, player)) {
      newState = actor.collide(newState);
    }
  }
  return newState;
};

このメソッドには、時間ステップと、押されているキーを示すデータ構造が渡されます。最初に、すべてのアクタに対してupdateメソッドを呼び出し、更新されたアクタの配列を作成します。アクタには時間ステップ、キー、状態も渡され、それらに基づいて更新を行うことができます。キーボードで制御されるのはプレイヤーのみであるため、キーを実際に読み取るのもプレイヤーだけです。

ゲームがすでに終了している場合、それ以上の処理は必要ありません(ゲームは敗北後には勝利できず、その逆も同様です)。そうでない場合、メソッドはプレイヤーが背景の溶岩に触れているかどうかをテストします。もしそうであれば、ゲームは終了し、処理は完了です。最後に、ゲームが本当に続いている場合、他のアクタがプレイヤーと重なっているかどうかを確認します。

アクタ間の重複は、overlap関数で検出されます。この関数は2つのアクタオブジェクトを受け取り、それらが触れている場合(x軸とy軸の両方でオーバーラップしている場合)にtrueを返します。

function overlap(actor1, actor2) {
  return actor1.pos.x + actor1.size.x > actor2.pos.x &&
         actor1.pos.x < actor2.pos.x + actor2.size.x &&
         actor1.pos.y + actor1.size.y > actor2.pos.y &&
         actor1.pos.y < actor2.pos.y + actor2.size.y;
}

アクタが重複している場合、そのcollideメソッドが状態を更新する機会を得ます。溶岩アクタに触れると、ゲームの状態が"lost"に設定されます。コインに触れるとコインは消え、それがレベルの最後のコインの場合、状態は"won"に設定されます。

Lava.prototype.collide = function(state) {
  return new State(state.level, state.actors, "lost");
};

Coin.prototype.collide = function(state) {
  let filtered = state.actors.filter(a => a != this);
  let status = state.status;
  if (!filtered.some(a => a.type == "coin")) status = "won";
  return new State(state.level, filtered, status);
};

アクタの更新

アクタオブジェクトのupdateメソッドは、時間ステップ、状態オブジェクト、keysオブジェクトを引数として受け取ります。Lavaアクタタイプのupdateメソッドは、keysオブジェクトを無視します。

Lava.prototype.update = function(time, state) {
  let newPos = this.pos.plus(this.speed.times(time));
  if (!state.level.touches(newPos, this.size, "wall")) {
    return new Lava(newPos, this.speed, this.reset);
  } else if (this.reset) {
    return new Lava(this.reset, this.speed, this.reset);
  } else {
    return new Lava(this.pos, this.speed.times(-1));
  }
};

このupdateメソッドは、時間ステップと現在の速度の積を古い位置に加算することで、新しい位置を計算します。その新しい位置を妨げる障害物がなければ、そこに移動します。障害物がある場合、溶岩ブロックの種類に応じて動作が異なります。垂れる溶岩にはreset位置があり、何か物体に当たるとそこにジャンプバックします。跳ねる溶岩は、速度に-1を掛けることで速度を反転させ、反対方向に動き始めます。

コインは、updateメソッドを使用して揺れます。単に自分の正方形の中で揺れているため、グリッドとの衝突は無視します。

const wobbleSpeed = 8, wobbleDist = 0.07;

Coin.prototype.update = function(time) {
  let wobble = this.wobble + time * wobbleSpeed;
  let wobblePos = Math.sin(wobble) * wobbleDist;
  return new Coin(this.basePos.plus(new Vec(0, wobblePos)),
                  this.basePos, wobble);
};

wobbleプロパティは時間を追跡するためにインクリメントされ、次にMath.sinの引数として使用されて、波上の新しい位置を求めます。コインの現在の位置は、その基本位置とこの波に基づいたオフセットから計算されます。

残りはプレイヤー自身です。床に当たっても水平方向の動きを妨げず、壁に当たっても落下やジャンプの動きを止めないため、プレイヤーの動きは軸ごとに個別に処理されます。

const playerXSpeed = 7;
const gravity = 30;
const jumpSpeed = 17;

Player.prototype.update = function(time, state, keys) {
  let xSpeed = 0;
  if (keys.ArrowLeft) xSpeed -= playerXSpeed;
  if (keys.ArrowRight) xSpeed += playerXSpeed;
  let pos = this.pos;
  let movedX = pos.plus(new Vec(xSpeed * time, 0));
  if (!state.level.touches(movedX, this.size, "wall")) {
    pos = movedX;
  }

  let ySpeed = this.speed.y + time * gravity;
  let movedY = pos.plus(new Vec(0, ySpeed * time));
  if (!state.level.touches(movedY, this.size, "wall")) {
    pos = movedY;
  } else if (keys.ArrowUp && ySpeed > 0) {
    ySpeed = -jumpSpeed;
  } else {
    ySpeed = 0;
  }
  return new Player(pos, new Vec(xSpeed, ySpeed));
};

水平方向の動きは、左右の矢印キーの状態に基づいて計算されます。この動きによって作成された新しい位置を妨げる壁がない場合、それが使用されます。そうでない場合は、古い位置が保持されます。

垂直方向の動きは同様の方法で機能しますが、ジャンプと重力をシミュレートする必要があります。プレイヤーの垂直速度(ySpeed)は、最初に重力を考慮して加速されます。

再び壁を確認します。壁に当たらない場合、新しい位置が使用されます。壁に当たった場合、2つの可能性があります。上矢印キーが押されていて、下向きに移動している場合(つまり、当たった物が自分の下にある場合)、速度は比較的大きな負の値に設定されます。これにより、プレイヤーはジャンプします。そうでない場合、プレイヤーは単に何かにぶつかり、速度はゼロに設定されます。

ゲームの重力強度、ジャンプ速度、その他の定数は、いくつかの数値を試してみて、どれが適切に感じられるかを判断することで決定されました。自由に実験してみてください。

キーの追跡

このようなゲームでは、キーをキーを押すごとに1回だけ有効にすることは望ましくありません。むしろ、(プレイヤーフィギュアを移動する)その効果は、キーが押されている限りアクティブな状態を維持する必要があります。

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

次の関数は、キー名の配列が与えられると、それらのキーの現在の位置を追跡するオブジェクトを返します。この関数は、"keydown"イベントと"keyup"イベントのイベントハンドラーを登録し、イベントのキーコードが追跡しているコードの集合に存在する場合、オブジェクトを更新します。

function trackKeys(keys) {
  let down = Object.create(null);
  function track(event) {
    if (keys.includes(event.key)) {
      down[event.key] = event.type == "keydown";
      event.preventDefault();
    }
  }
  window.addEventListener("keydown", track);
  window.addEventListener("keyup", track);
  return down;
}

const arrowKeys =
  trackKeys(["ArrowLeft", "ArrowRight", "ArrowUp"]);

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

ゲームの実行

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

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

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

最大フレームステップを100ミリ秒(1/10秒)に設定しました。ブラウザのタブまたはページを含むウィンドウが非表示になると、requestAnimationFrameの呼び出しは、タブまたはウィンドウが再び表示されるまで中断されます。この場合、lastTimetimeの差は、ページが非表示になっていた全体の時間になります。一度にそれだけの量だけゲームを進めると、おかしな見た目になり、プレイヤーが床をすり抜けるなど、奇妙な副作用を引き起こす可能性があります。

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

runLevel関数は、Levelオブジェクトとディスプレイコンストラクタを受け取り、Promiseを返します。これはレベルを(document.bodyに)表示し、ユーザーがプレイできるようにします。レベルが終了すると(負けまたは勝ち)、runLevelは1秒間待機し(ユーザーが何が起こったかを確認できるように)、表示をクリアし、アニメーションを停止し、ゲームの終了ステータスにPromiseを解決します。

function runLevel(level, Display) {
  let display = new Display(document.body, level);
  let state = State.start(level);
  let ending = 1;
  return new Promise(resolve => {
    runAnimation(time => {
      state = state.update(time, arrowKeys);
      display.syncState(state);
      if (state.status == "playing") {
        return true;
      } else if (ending > 0) {
        ending -= time;
        return true;
      } else {
        display.clear();
        resolve(state.status);
        return false;
      }
    });
  });
}

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

async function runGame(plans, Display) {
  for (let level = 0; level < plans.length;) {
    let status = await runLevel(new Level(plans[level]),
                                Display);
    if (status == "won") level++;
  }
  console.log("You've won!");
}

第11章に示すように、runLevelがPromiseを返すようにしたので、runGameは非同期関数を使用して記述できます。これは、プレイヤーがゲームを終了したときに解決される別のPromiseを返します。

この章のサンドボックスGAME_LEVELSバインディングには、利用可能なレベルプランのセットがあります。このページはそれらをrunGameに渡し、実際のゲームを開始します。

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

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

それらをクリアしてみてください。私はそれを作るのが楽しかったです。

練習問題

ゲームオーバー

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

ライフを実装するようにrunGameを調整してください。プレイヤーは3つから始めます。レベルが開始するたびに、現在のライフの数(console.logを使用して)を出力します。

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

<body>
<script>
  // The old runGame function. Modify it...
  async function runGame(plans, Display) {
    for (let level = 0; level < plans.length;) {
      let status = await runLevel(new Level(plans[level]),
                                  Display);
      if (status == "won") level++;
    }
    console.log("You've won!");
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>

ゲームの一時停止

escキーを押すと、ゲームを一時停止(中断)および再開できるようにします。これを行うには、runLevel関数を変更して、escキーが押されるたびにアニメーションを中断または再開するキーボードイベントハンドラーを設定します。

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

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

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

<body>
<script>
  // The old runLevel function. Modify this...
  function runLevel(level, Display) {
    let display = new Display(document.body, level);
    let state = State.start(level);
    let ending = 1;
    return new Promise(resolve => {
      runAnimation(time => {
        state = state.update(time, arrowKeys);
        display.syncState(state);
        if (state.status == "playing") {
          return true;
        } else if (ending > 0) {
          ending -= time;
          return true;
        } else {
          display.clear();
          resolve(state.status);
          return false;
        }
      });
    });
  }
  runGame(GAME_LEVELS, DOMDisplay);
</script>
</body>
ヒントの表示...

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

そのため、ゲームを一時停止しているという事実を、runAnimationに渡された関数に伝える必要があります。そのためには、イベントハンドラーと関数の両方がアクセスできるバインディングを使用できます。

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

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

モンスター

プラットフォームゲームでは、上にジャンプすることで倒せる敵がいるのが一般的です。この練習問題では、ゲームにそのようなアクタータイプを追加するように求めています。

このアクターをモンスターと呼びます。モンスターは水平方向にのみ移動します。プレイヤーの方向に移動させたり、水平方向の溶岩のように前後にバウンスさせたり、その他の任意の移動パターンを持たせることができます。このクラスは落下を処理する必要はありませんが、モンスターが壁をすり抜けないようにする必要があります。

モンスターがプレイヤーに触れると、その効果はプレイヤーがモンスターの上にジャンプしているかどうかによって異なります。これは、プレイヤーの下部がモンスターの上部に近いかどうかを確認することで近似できます。これが当てはまる場合、モンスターは消えます。そうでない場合、ゲームは敗北します。

<link rel="stylesheet" href="css/game.css">
<style>.monster { background: purple }</style>

<body>
  <script>
    // Complete the constructor, update, and collide methods
    class Monster {
      constructor(pos, /* ... */) {}

      get type() { return "monster"; }

      static create(pos) {
        return new Monster(pos.plus(new Vec(0, -1)));
      }

      update(time, state) {}

      collide(state) {}
    }

    Monster.prototype.size = new Vec(1.2, 2);

    levelChars["M"] = Monster;

    runLevel(new Level(`
..................................
.################################.
.#..............................#.
.#..............................#.
.#..............................#.
.#...........................o..#.
.#..@...........................#.
.##########..............########.
..........#..o..o..o..o..#........
..........#...........M..#........
..........################........
..................................
`), DOMDisplay);
  </script>
</body>
ヒントの表示...

バウンスなど、状態を保持する種類のモーションを実装する場合は、必要な状態をアクターオブジェクトに格納してください。コンストラクタの引数として含め、プロパティとして追加します。

updateは古いオブジェクトを変更するのではなく、新しいオブジェクトを返すことを忘れないでください。

衝突を処理する際には、state.actorsでプレイヤーを見つけ、その位置をモンスターの位置と比較します。プレイヤーの下部を取得するには、垂直位置に垂直サイズを追加する必要があります。更新された状態の作成は、プレイヤーの位置に応じて、Coincollideメソッド(アクターの削除)またはLavaのメソッド(ステータスを"lost"に変更)に似ています。