第4版 が利用可能です。こちらをお読みください

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

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

イアン・バンクス, ゲームの達人
Picture of a game character jumping over lava

私を含めた多くのオタクな子供たちにとって、コンピュータに対する初期の魅力の多くは、コンピュータゲームに起因していました。私は、自分が操作できる小さなシミュレートされた世界に惹きつけられ、その中で物語が(ある程度)展開されていきました。それは、実際に提供されていた可能性よりも、私が自分の想像力をそこに投影した方法によるものだったと思います。

私は、誰にもゲームプログラミングのキャリアを勧めたいとは思いません。音楽業界とよく似ていますが、そこで働きたいと熱望する若い人々の数と、そのような人材に対する実際の需要との間には大きな乖離があり、非常に不健全な環境が生まれています。しかし、趣味でゲームを作るのは楽しいものです。

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

ゲーム

私たちのゲームは、トーマス・パレフによるDark Blueを大まかにベースにします。私がそのゲームを選んだのは、それが面白くてミニマルであり、あまりコードを書かずに作ることができるからです。それはこんな感じです。

The game Dark Blue

黒い箱はプレイヤーを表し、その任務は赤いもの(溶岩)を避けながら、黄色い箱(コイン)を集めることです。すべてのコインが集められると、レベルが完了します。

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

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

技術

ゲームの表示にはブラウザの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") return type;
        this.startActors.push(
          type.create(new Vec(x, y), ch));
        return "empty";
      });
    });
  }
}

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

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

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

プラン内の文字を解釈するために、Levelコンストラクターは、背景要素を文字列に、アクター文字をクラスにマッピングするlevelCharsオブジェクトを使用します。typeがアクタークラスの場合、その静的なcreateメソッドを使用してオブジェクトを作成し、startActorsに追加します。また、マッピング関数はこの背景の正方形に対して"empty"を返します。

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

ゲームが実行されると、アクターは別の場所に移動したり、完全に消えたりします(コインが収集時に消えるように)。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"に切り替わります。

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

アクター

アクターオブジェクトは、ゲーム内の特定の動く要素の現在の位置と状態を表します。すべてのアクターオブジェクトは同じインターフェイスに準拠しています。それらのposプロパティには、要素の左上隅の座標が保持され、sizeプロパティにはサイズが保持されます。

次に、updateメソッドがあります。これは、指定された時間ステップ後の新しい状態と位置を計算するために使用されます。アクターが行うこと(プレイヤーの場合は矢印キーに応答して移動し、溶岩の場合は前後に跳ね返る)をシミュレートし、新しく更新されたアクターオブジェクトを返します。

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

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

これは、アクターの位置やサイズなど、2次元の値に使用する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のようにgetterを使用することもできますが、そうするとプロパティが読み込まれるたびに新しい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プロパティを格納します。これらを合わせることで、コインの実際の位置(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

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

カプセル化は負担である

この章のコードのほとんどは、2つの理由から、カプセル化をあまり気にしていません。まず、カプセル化には余分な労力がかかります。プログラムが大きくなり、追加の概念とインターフェースを導入する必要があります。読者が目がかすむまでに投げつけられるコードには限りがあるので、プログラムを小さくするように努めてきました。

第二に、このゲームのさまざまな要素は非常に密接に結び付いているため、要素の1つの動作が変更された場合、他の要素が同じ状態を維持できる可能性は低くなります。要素間のインターフェースは、ゲームの動作方法に関する多くの前提をエンコードすることになります。これにより、効果が大幅に低下します。システムの1つの部分を変更するたびに、インターフェースが新しい状況をカバーしないため、他の部分への影響についても心配する必要があります。

システム内のいくつかの切断点は、厳密なインターフェースを通じて分離するのに適していますが、そうでないものもあります。適切ではない境界線をカプセル化しようとすると、多くのエネルギーを無駄にすることになります。この間違いを犯しているときは、インターフェースがぎこちなく大きくて詳細になり、プログラムが進化するにつれて頻繁に変更する必要があることに気付くでしょう。

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

描画

描画コードのカプセル化は、与えられたレベルと状態を表示する表示オブジェクトを定義することによって行われます。この章で定義する表示タイプは、レベルを表示するために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-spacing、およびpadding)は、不要なデフォルトの動作を抑制するために使用されます。テーブルのレイアウトがセルの内容に依存することは望ましくなく、テーブルセル間やその内部にスペースやパディングは不要です。

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型で定義したメソッドを使うことで、オブジェクトを使った計算を比較的読みやすく記述できることがわかります。アクターの中心を見つけるには、その位置(左上隅)とサイズの半分を加算します。これはレベル座標における中心ですが、ピクセル座標で必要となるため、結果のベクトルに表示スケールを掛けます。

次に、一連のチェックで、プレイヤーの位置が許容範囲外ではないことを確認します。場合によっては、これがゼロ未満または要素のスクロール可能領域を超えた無意味なスクロール座標を設定することがあることに注意してください。これは問題ありません。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 x 1単位であることを忘れないでください。ボックスの側面を切り上げたり切り下げたりすることで、ボックスが接触する背景正方形の範囲を取得します。

Finding collisions on a grid

座標を丸めることで見つかったグリッド正方形のブロックをループし、一致する正方形が見つかった場合に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つのアクターオブジェクトを取り、それらが接触している場合にtrueを返します。これは、x軸とy軸の両方で重なっている場合に当てはまります。

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アクタータイプのものは、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つの可能な結果があります。上矢印キーが押されていて、私たちが下に移動している場合(つまり、ぶつかったものが私たちの下にある場合)、速度は比較的大きな負の値に設定されます。これにより、プレイヤーがジャンプします。そうでない場合は、プレイヤーが何かにぶつかっただけで、速度はゼロに設定されます。

このゲームの重力の強さ、ジャンプ速度、およびほぼすべての定数は、試行錯誤によって設定されました。私は気に入る組み合わせが見つかるまで値をテストしました。

キーの追跡

このようなゲームでは、キーを押すごとにキーが一度だけ有効になることを望みません。むしろ、キーを押している間(プレイヤーの動きなど)の効果がアクティブなままであることを望みます。

左、右、上矢印キーの現在の状態を保存するキーハンドラーを設定する必要があります。また、これらのキーで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ミリ秒(10分の1秒)に設定しました。ページのあるブラウザのタブやウィンドウが非表示になると、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!");
}

runLevelがPromiseを返すようにしたため、runGame第11章で示したように、async関数を使って記述できます。これは、プレイヤーがゲームを終了したときに解決する別の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"に変更する)に似たものになります。