HTML5 を使ったシンプルな 2 D ゲームの作り方 (複数のSpriteの生成)
ずいぶんと間があいてしまいましたが、HTML5 を使用したシンプルな 2D ゲームの作り方の第 7 回めをお送りしたいと思います。
どんなゲームを作るのかは 1 回目の記事の中に実際に動作するゲームが埋め込んであるのでぜひ遊んでみてください。なお、開発に必要な画像データは 2 回目の記事からダウンロードできますので、実際にゲーム開発を体験したい方はそちらから入手してください。
- HTML5 を使ったシンプルな 2 D ゲームの作り方(序)
- HTML5 を使ったシンプルな 2 D ゲームの作り方(準備編)
- HTML5 を使ったシンプルな 2 D ゲームの作り方 (画像のロード)
- HTML5 を使ったシンプルな 2 D ゲームの作り方 (アニメーションの実装)
- HTML5 を使ったシンプルな 2 D ゲームの作り方 (矢印キーとタッチによる制御の実装)
- HTML5 を使ったシンプルな 2 D ゲームの作り方 (当たり判定の実装)
このシリーズの前回となる第 6 回めの記事では、落ちてくる雪の結晶と雪だるまのあたり判定について紹介しました。
今回は落ちて来る雪の結晶を増やす方法と、それら生成された複数の雪の結晶の実態 (インスタンス) を制御する方法について紹介します。
これらについて理解するには段階的に以下のことを理解していく必要があります。
- クラスの定義
- インスタンスの生成
- インスタンスの制御
上記の概念はゲームだけでなく一般的なプログラミングにも必要な概念ですので、ご存じない方はこの機会にぜひチャレンジしてみてください。
クラス
このゲームに表示される雪の結晶の数を増やすことは、image オブジェクトをCanvan に追加する処理を複数回記述することで実装することもできます。
ただ、それでは同じような処理を何度も繰り返し記述する必要が出てきたり、追加した複数の画像の管理が煩雑になります。
これを効率よく行うには、画像も含め、雪の結晶が動作するのに必要なデータをまとめたクラスを定義し、必要に応じて必要個数ぶんインスタンスを生成します。ちなみに「クラス」とは「オブジェクト」の定義(設計図 のようなもの) であり、new することでその定義されたオブジェクトの実体 (インスタンス) を生成して増やすことができます。生成されたインスタンスはそれぞれのプロパティに固有の値を保持することができます。
JavaScript におけるクラスの定義
ECMA Script 2015(ECMA Script 6) から、JavaScript でもクラスを定義する構文が使用できるようになりますが、現在まだ主流で使用されている ECMA Script 5 の JavaScript にはクラスを定義する構文はありません。しかし、function を使用することである程度の同様の機能を実装することができます。
例えば以下は Human という関数を定義し、その後 new キーワードで 2 つの異なるインスタンスを生成しています。ちなみに new でインスタンスを生成する関数は名前の先頭を大文字にするのが JavaScript では慣例となっています。
//Human クラスの定義
var Human = function(name){
this.name = name;
this.myNameIs = function(){return name};
};
//Human クラスから異なる情報をもったインスタンスを生成
var taro = new Human(‘タロウ’)
var jiro = new Human(‘ヂロウ’)
console.log(taro.myNameIs()); //’タロウ’ が返る
console.log(jiro.myNameIs()); //’ヂロウ’ が返る
Sprite クラスの定義とインスタンスの生成
ゲーム画面に表示されるキャラクターなどの要素を慣例的に Sprite (スプライト) と呼びます。スプライトとは、もともとは霊や精神などを意味するものです。
(※) 画像処理の方法についても スプライト と呼ばれるものがありますが、ここでのスプライトはゲームのキャラクタを表します。
ゲーム中の雪の結晶を制御するための Sprite クラスを定義します。Sprite という名前の関数を定義し、必要なプロパティを外部からアクセス可能な変数で定義していきます。なお、JavaScript のオブジェクトはアクセスした段階で動的に生成されるので、動作的にはあらかじめ定義しておく必要がないものもありますが、そのクラスがどのようなプロパティをもっているかを明確にするために記述するようにしましょう。
また生成した複数の Sprite クラスのインスタンスを格納する配列 (snow_sprites ) も定義しておきましょう。
前回までに記述したソースコードの変数の宣言部分の最後に以下のコードを追加します。
//雪だるまの Sprite のインスタンスを格納する変数
var sprite_snow_man = null;
//雪の結晶の Sprite のインスタンスを格納する配列
var snow_sprites = [];
//Sprite クラスの定義
var Sprite = function(img){
this.image = img; //image オブジェクト
this.height = img.height;
this.width = img.width;
this.x = 0; //表示位置 x
this.y = 0; //表示位置 y
this.dx = 0; //移動量 x
this.dy = 0; //移動量 y
}
定義した Sprite クラスのインスタンスを生成するには以下のように記述します。
var sprite_snow = new Sprite(img_snow);
表示する雪の結晶の数と、それらの間に設ける幅のピクセル数を記述します。
表示する雪の結晶の数は、Canvas の幅が 380px で雪の結晶の画像幅が 38px なので、横に隙間なく 10 個並べることができますが、それだど窮屈な感じがするので、 6 個とし、その間の幅は 20px とします。隣り合う画像を表示する際の各々の x 位置は「隣り合う画像同士の x の距離」となるので 38px + 20px で 58px になります。
この 「隣り合う画像の x 位置の距離」 を定数として記述します。
Internet Explorer 11 では定数を定義するための const キーワードを使用することができます。const は変数のようなデータの入れ物を宣言するものではなく、実値に定数としての名前を付けるものなので、プログラムで誤って内容を書き換えてしまうということはなくなります。(※本来はそうですが、実際は Web ブラウザーの実装により挙動が異なります)
const キーワードは、他のメジャーな Web ブラウザーでは以前から使用できていたものの、11 以前の Internet Explorer では使用できなかったので、ユーザーの批判にさらされたりしましたが、じつは、この const キーワード、 ECMA Script 5 の一部ではなく Mozilla さんの独自実装なのです。
const キーワードは ECMA Script 2015(ECMA Script 6) では機能の一部となっているので、それらを意識した作りになっている Internet Explorer 11 ではサポートされています。そのため Internet Explorer 11 では、ECMA Script 2015 の仕様に乗っ取り const で宣言された定数のスコープはブロック単位になります。
説明が長くなりましたが“隣り合う画像の x 位置の差” を以下のように定数として記述します。ただし、古いバージョンのブラウザーとの互換性を考慮する必要がある場合は const は使用せず var を使用するようにしてください。記述する位置はコメント「//雪だるまの Sprite のインスタンスを格納する変数 」の前が良いでしょう。
//表示する雪の結晶の数
const SNOWS_COUNT = 6;
//隣り合う 雪の結晶画像の x 位置の差分
const NEIGHBOR_DISTANCE = 58;
上記の点を踏まえ、ゲームキャラクターのイメージをロードする loadAssets 関数を以下のように書き換えます。
function loadAssets() {
//HTML ファイル上の canvas エレメントのインスタンスを取得
canvas = document.getElementById('bg');
//アニメーションの開始
canvas.addEventListener("click", renderFrame);
//2D コンテキストを取得
ctx = canvas.getContext('2d');
//image オブジェクトのインスタンスを生成
img_snow = new Image();
//image オブジェクトに画像をロード
img_snow.src = '/img/snow.png';
/*画像読み込み完了のイベントハンドラーに Canvas に
画像を表示するメソッドを記述 */
img_snow.onload = function () {
for (var i = 0; i < SNOWS_COUNT; i++) {
//画像を引数に Sprite クラスのインスタンスを生成
var sprite_snow = new Sprite(img_snow);
sprite_snow.dy = 1;
sprite_snow.dx = NEIGHBOR_DISTANCE;
sprite_snow.x = i * sprite_snow.dx;
snow_sprites.push(sprite_snow);
sprite_snow = null;
}
};
//雪だるま画像のロード
img_snow_man = new Image();
img_snow_man.src = '/img/snow_man.png';
img_snow_man.onload = function () {
//画像を引数に Sprite クラスのインスタンスを生成
sprite_snow_man = new Sprite(img_snow_man);
sprite_snow_man.x = getCenterPostion(canvas.clientWidth, img_snow_man.width);
sprite_snow_man.y = canvas.clientHeight - img_snow_man.height;
sprite_snow_man.limit_rightPosition = getRightLimitPosition(
canvas.clientWidth, img_snow_man.width);
};
};
ゲームで使用するアセットがロードされたかを調べる関数
今回作成しているゲームは、使用している画像のサイズが小さく、動作の開始も画面をクリックした後に行われることから、画像などのアセット類がロードされる前にそれらにアクセスしてエラーになるといったことはあまり心配する必要はありません。しかしながら、使用する画像の数やサイズが増えたり、サウンドや動画のようなサイズの大きなファイルを扱う際には、ゲームで使用するアセット全体のロードの完了に時間がかかる場合があります。そういった問題に対処するためのアセット類がロードされたかどうかをチェックする関数を作成します。
この関数は、ゲームで使用される Sprite オブジェクトのインスタンス調べ、インスタンスの生成かまだの場合は requestAnimationFrame 関数を使用してインスタンスの生成が完了するまで待ちます。requestAnimationFrame 関数はループ中に制御を返すので、待機中を示すプログレスバーなどを別途用意して表示しても良いでしょう。アセットのロードが完了したらゲームのアニメーションを開始します。
//ゲームで使用する Splite オブジェクトが準備されたかどうかを判断
function loadCheck() {
if (snow_sprites.length && sprite_snow_man) {
//準備ができたらアニメーションを開始
window.requestAnimationFrame(renderFrame);
} else {
//まだの場合はループして待機
window.requestAnimationFrame(loadCheck);
}
}
上記を踏まえ、loadAssets 関数内の以下のイベントハンドラーを
canvas.addEventListener("click", renderFrame);
以下のように書き換えます。(この一行は、メンテナンスを考えると本当は setHandler 関数内に書くべきなのですが。。)
canvas.addEventListener("click", loadCheck);
Sprite インスタンスへのプロパティ設定
このゲームのアニメーションは renderFrame メソッドをループし、その内部でゲームキャラクターの image オブジェクトの位置を表す _x、_y プロパティの値を加算/減算することで実現しています。しかし、前段でゲームキャラクターは Sprite クラスのインスタンスで制御するように変更しました。よって renderFrame メソッド内の image オブジェクトへのプロパティ設定記述を Sprite クラスでのインスタンスに変更する必要があります。
ゲーム中の 雪だるま を表す記述はすべて以下のように変更します。
img_snow_man. _x → sprite_snow_man.x
img_snow_man. _y → sprite_snow_man.y
ctx.drawImage(img_snow_man, img_snow_man. _x, img_snow_man. _y);
↓
ctx.drawImage(sprite_snow_man.image, sprite_snow_man.x, sprite_snow_man.y);
なお、Sprite インスタンスのプロパティ名には _ (アンダーバー) がつかないのでご注意ください(※)。このプロパティ名の違いの関係で、isHit 関数内も以下のように記述を変更してください。
targetA. _x → targetA.x
targetA. _y → targetA.y
targetB._x → targetB.x
targetB. _y → targetA.y
(※)もともと、image オブジェクトのプロパティ名に _ (アンダーライン) はつけないはずだったのですが、image オブジェクト.x のように記述すると Chrome と Firefox でエラーになるので image オブジェクト._x としていました。
雪の結晶の場合は、これまでとは違い 6 つのオブジェクトをコントロールする必要があるので、そのインスタンスが格納されている配列 snow_sprites をループして処理を行います。
具体的には以下のようなコードになります。
//配列 snow_sprites のアイテム数を取得
var length = snow_sprites.length;
for (var i = 0; i < length; i++) {
var snow_sprite = snow_sprites[i];
//雪 の結晶の y 値(縦位置) が canvas からはみ出たら先頭に戻す
if (snow_sprite.y > canvas.clientHeight) {
snow_sprite.y = 0
};
//雪の結晶の y 値を増分
snow_sprite.y += snow_sprite.dy;
//画像を描画
ctx.drawImage(snow_sprite.image, snow_sprite.x, snow_sprite.y);
//当たり判定
isHit(snow_sprite, sprite_snow_man.image);
snow_sprite = null;
}
上記の内容を反映させた renderFrame メソッドのコードは以下のようになります。
function renderFrame() {
//canvas をクリア
ctx.clearRect(0, 0, canvas.width, canvas.height);
//sprite_snow_man の x 値が動作範囲内かどうか
if ((sprite_snow_man.x < sprite_snow_man.limit_rightPosition && key_value > 0)
|| (sprite_snow_man.x >= 3 && key_value < 0)) {
//img_snow_man の x 値を増分
sprite_snow_man.x += key_value;
}
var length = snow_sprites.length;
for (var i = 0; i < length; i++) {
var snow_sprite = snow_sprites[i];
//snow_sprite の y 値(縦位置) が canvas からはみ出たら先頭に戻す
if (snow_sprite.y > canvas.clientHeight) {
snow_sprite.y = 0
//snow_sprite.y = getRandomPosition(6, -50);
};
//snow_sprite の y 値を増分
snow_sprite.y += snow_sprite.dy;
//画像を描画
ctx.drawImage(snow_sprite.image, snow_sprite.x, snow_sprite.y);
//当たり判定
isHit(snow_sprite, sprite_snow_man);
snow_sprite = null;
}
//画像を描画
ctx.drawImage(sprite_snow_man.image, sprite_snow_man.x, sprite_snow_man.y);
window.requestAnimationFrame(renderFrame);
}
上記の内容を反映させた isHit 関数のコードは以下のようになります。
//当たり判定
function isHit(targetA, targetB) {
if ((targetA._x <= targetB._x && targetA.width + targetA._x >= targetB._x)
|| (targetA._x >= targetB._x && targetB._x + targetB.width >= targetA._x)) {
if ((targetA._y <= targetB._y && targetA.height + targetA._y >= targetB._y)
|| (targetA._y >= targetB._y && targetB._y + targetB.height >= targetA._y)) {
ctx.font = "bold 20px 'MS ゴシック'";
ctx.fillStyle = "red";
ctx.fillText("ヒットしました", getCenterPostion(canvas.clientWidth, 140), 160);
}
}
}
実行してみると、雪の結晶が 6 つならんで上から移動するようになっています。
以下の黒いボックスをクリックして動作を確認してください。
main.js の完全なソースコードは以下になります。
(function () {
//矢印キーのコード
var LEFT_KEY_CODE = 37;
var RIGHT_KEY_CODE = 39;
var key_value = 0;
//全体で使用する変数
var canvas = null;
var ctx = null;
var img_snow = null;
var img_snow_man = null;
//表示する雪の結晶の数
const SNOWS_COUNT = 6;
//隣り合う 雪の結晶画像の x 位置の差分
const NEIGHBOR_DISTANCE = 58;
//雪だるまの Sprite のインスタンスを格納する配列
var sprite_snow_man = null;
//雪の Sprite のインスタンスを格納する配列
var snow_sprites = [];
//Sprite クラスの定義
var Sprite = function (img) {
this.image = img; //image オブジェクト
this.height = img.height;
this.width = img.width;
this.x = 0; //表示位置 x
this.y = 0; //表示位置 y
this.dx = 0; //移動量 x
this.dy = 0; //移動量 y
}
//DOM のロードが完了したら実行
document.addEventListener("DOMContentLoaded", function () {
loadAssets();
setHandlers();
});
function setHandlers() {
//キーイベントの取得 (キーダウン)
document.addEventListener("keydown", function (evnt) {
if (evnt.which == LEFT_KEY_CODE) {
key_value = -3;
} else if (evnt.which == RIGHT_KEY_CODE) {
key_value = 3;
}
});
//雪だるまが進みっぱなしにならないように、 キーが上がったら 0 に
document.addEventListener("keyup", function () {
key_value = 0;
});
//Canvas へのタッチイベント設定
canvas.addEventListener("touchstart", function (evnt) {
if ((canvas.clientWidth / 2) > evnt.touches[0].clientX) {
key_value = -3;
} else {
key_value = 3;
}
});
//雪だるまが進みっぱなしにならないように、 タッチが完了したら 0 に
canvas.addEventListener("touchend", function (evnt) {
key_value = 0;
});
}
function loadAssets() {
//HTML ファイル上の canvas エレメントのインスタンスを取得
canvas = document.getElementById('bg');
//アニメーションの開始
canvas.addEventListener("click", loadCheck);
//2D コンテキストを取得
ctx = canvas.getContext('2d');
//image オブジェクトのインスタンスを生成
img_snow = new Image();
//image オブジェクトに画像をロード
img_snow.src = '/img/snow.png';
/*画像読み込み完了のイベントハンドラーに Canvas に
画像を表示するメソッドを記述 */
img_snow.onload = function () {
for (var i = 0; i < SNOWS_COUNT; i++) {
var sprite_snow = new Sprite(img_snow);
sprite_snow.dy = 1;
sprite_snow.dx = NEIGHBOR_DISTANCE;
sprite_snow.x = i * sprite_snow.dx;
snow_sprites.push(sprite_snow);
sprite_snow = null;
}
};
//雪だるま画像のロード
img_snow_man = new Image();
img_snow_man.src = '/img/snow_man.png';
img_snow_man.onload = function () {
sprite_snow_man = new Sprite(img_snow_man);
sprite_snow_man.x = getCenterPostion(canvas.clientWidth, img_snow_man.width);
sprite_snow_man.y = canvas.clientHeight - img_snow_man.height;
sprite_snow_man.limit_rightPosition = getRightLimitPosition(canvas.clientWidth, img_snow_man.width);
};
};
//ゲームで使用する Splite オブジェクトが準備されたかどうかを判断
function loadCheck() {
if (snow_sprites.length && sprite_snow_man) {
//準備ができたらアニメーションを開始
window.requestAnimationFrame(renderFrame);
} else {
//まだの場合はループして待機
window.requestAnimationFrame(loadCheck);
}
}
function renderFrame() {
//canvas をクリア
ctx.clearRect(0, 0, canvas.width, canvas.height);
//sprite_snow_man の x 値が動作範囲内かどうか
if ((sprite_snow_man.x < sprite_snow_man.limit_rightPosition && key_value > 0)
|| (sprite_snow_man.x >= 3 && key_value < 0)) {
//img_snow_man の x 値を増分
sprite_snow_man.x += key_value;
}
var length = snow_sprites.length;
for (var i = 0; i < length; i++) {
var snow_sprite = snow_sprites[i];
//snow_sprite の y 値(縦位置) が canvas からはみ出たら先頭に戻す
if (snow_sprite.y > canvas.clientHeight) {
snow_sprite.y = 0
//snow_sprite.y = getRandomPosition(6, -50);
};
//snow_sprite の y 値を増分
snow_sprite.y += snow_sprite.dy;
//画像を描画
ctx.drawImage(snow_sprite.image, snow_sprite.x, snow_sprite.y);
//当たり判定
isHit(snow_sprite, sprite_snow_man);
snow_sprite = null;
}
//画像を描画
ctx.drawImage(sprite_snow_man.image, sprite_snow_man.x, sprite_snow_man.y);
window.requestAnimationFrame(renderFrame);
}
//中央に配置する画像の X 座標を求める関数
function getCenterPostion(containerWidth, itemWidth) {
return (containerWidth / 2) - (itemWidth / 2);
};
//Player (雪だるまを動かせる右の限界位置)
function getRightLimitPosition(containerWidth, itemWidth) {
return containerWidth - itemWidth;
}
//当たり判定
function isHit(targetA, targetB) {
if ((targetA.x <= targetB.x && targetA.width + targetA.x >= targetB.x)
|| (targetA.x >= targetB.x && targetB.x + targetB.width >= targetA.x)) {
if ((targetA.y <= targetB.y && targetA.height + targetA.y >= targetB.y)
|| (targetA.y >= targetB.y && targetB.y + targetB.height >= targetA.y)) {
ctx.font = "bold 20px 'MS ゴシック'";
ctx.fillStyle = "red";
ctx.fillText("ヒットしました", getCenterPostion(canvas.clientWidth, 140), 160);
}
}
}
})();
まとめ
今回はクラスという概念を JavaScript の関数を使用して実装しました。クラスは、同一の構造に異なる状態を保持するオブジェクト生成に使用できるだけでなく、継承を使用して他のクラス (親クラス) の構造を引き継ぎ、そのうえに独自の構造や機能を追加することもできます。詳細については以下をご覧ください。
次回は
上から降ってくる雪の結晶を 6 つに増やしましたが、次回はこの 6 つの雪の結晶が降ってくるタイミングを各々ランダムに変化させます。
また、このプログラムではアニメーション用のループ処理を requestAnimationFrame メソッドで行っていますが、おそらくお気づきの人もいるかと思うですが、Web ブラウザーによってスピードや滑らかさが異なります。
さらに現在のままだと、一秒間に何回ループが行われるのか不明なので、これをコントロールする機能を実装します。