ゲームエンジンやライブラリを使わずに、HTMLとJavaScriptだけでゲームを作ります。
以下のような人のためにあります。
ゲームを作ってみたいけど、何をどうしていいか分からない。
単純なゲームを作りたいだけなのに、UnityとかUnreal Engineは面倒くさい。
ある程度プログラミングの知識がある人を対象にしています。
何を作るのか?
「マリオブラザーズ」風のジャンプアクションゲームを作ろう、の第二回です。今回は以下のことを実現します。
- 敵キャラ:立っている床が突き上げられたらひっくり返る
- パワーブロック:ステージ全体が揺れて、床に立っている敵キャラは全員ひっくり返る
この発想はどこから出てきたんだと思うほど、シンプルで天才的なギミックだと思います。
素晴らしいのは、敵キャラに対して直接に攻撃するのではなく間接的に攻撃するところ。しかも、ひっくり返ったあとに、もう一回ひっくり返して元に戻るとか。発想とゲーム性のバランス感覚が天才的です。
動作デモ
とりあえずデモを遊んでみてください。
ジョイパッドでも操作できます。ゲーム画面をダブルクリックするとフルスクリーンになるよ。
先に読んでおく記事
ソースコード
ソースコードのファイル構成は下記のとおりです。
.
├── Block.js
├── Chara.js
├── GObject.js
├── PBlock.js
├── Pipe.js
├── Snail.js
├── SpriteSheet.js
├── UserInput.js
├── World.js
├── assets
│ ├── chara_spritesheet.png
│ ├── pow_spritesheet.png
│ ├── snail_spritesheet.png
│ ├── stages.json
│ ├── style.css
│ └── tilesheet.png
├── index.html
└── index.js
ファイルがだいぶ増えましたが、画面の構成物であるPipeとBlockは前回も存在していて、クラスの定義を独立ファイルとして出しました。
新しく追加したゲーム内のオブジェクトは、Snai(Snai.js) と PowBlock (PBlock.js) だけです。
ゲームの構成物のクラス構成
今回の実装で、ゲーム内のオブジェクトのクラス構成を下記のようにしています。すべてのオブジェクトをGObjectの子供として扱います。
Chara、 Snailは動くオブジェクトでアニメーションもしますが、Pipe, PBlockはほとんどが静止していて、Blockは完全に固定物です。これらは親クラスのGObjectが持つアニメーション管理機能は使われません。が、将来的にこれらの固定物にも色々なギミックを追加することができるようになるので、汎用性を持たせるのも悪くありません。
とはいえ、作るゲームによって必要最小限のロジックを入れて実装できるのが、自分で全部プログラミングする場合の最大のメリットです。倉庫版やテトリスを作るのに今回のような設計では、実装が面倒になるだけです。
一方で、何かしらゲームエンジン(UnityとかUnreal Engine)を使うとなると、あまり自由度はありません。すでにあるフレームワークのお作法に従う必要があります。小さな2Dゲームを作る場合は、実現したいことに対して学習コストが高く無駄に手間が多くなります。
Snailの登場
いよいよ敵キャラの登場です。マリオの亀ならぬ、このデモではカタツムリを登場させました。
状態定義
Snailの動きを実現するための状態は下記の表のとおりです。
それぞれの状態で用意したアニメーションのパターンは必要最小限のものですが、実際のゲームの動きとしては十分な表現力になっていると思います。
ちなみに、Youtubeで「マリオブラザーズ」のアーケード版をじっくり観ると、結構細かいアニメーションのパターンがあるのが見て取れて、ディテールに相当こだわりを持って作られたのがわかります。ファミコン版だと簡略化されていますが。
床の突き上げ処理
プレイヤーキャラクターがジャンプして、頭上のブロックにあたるとブロックが上に跳ね上がります。この処理は、前回の記事から実装が変わっています。(そのままでも良かったのですが、より良い実装方法を思いついたので整理してすっきりさせました。実装している間にコードが変化していくのは普通のことですが、その変化する様も楽しんでもらえればと思います)。
プレイヤーのジャンプの状態のときに、何かに当たったかどうかを判断します。以下の実装の部分です。
action_jump_up() {
if (this.vy < 0) {
const ht = this.world.pushUpObj(this.head_area());
・・・
Worldクラスに対してマップ上のオブジェクト全部に当たっているものがあるかをチェックさせます。
pushUpObj(src) {
let ht = false;
for (let o of this.map) {
if (o.push_up(src))
ht = true;
}
return ht;
}
マップ上の配置物が跳ね上がるかどうかは、それぞれのオブジェクトのpush_up()メソッド内で処理します。Pipeの場合は下記の処理をしています。
push_up(src) {
if (src.y < this.y)
return false;
if (this.hit(src, this)) {
let p = src.x+src.w/2;
if (p >= this.x && p <= this.x+this.w) {
this.vy = src.vy/2;
} else {
this.vy = src.vy/4;
}
this.change_state(State.PUSH_UP);
return true;
}
return false;
}
細かい処理は置いといて、メインの処理は、if (this.hit(src.this)) で当たりと判断されたら、状態を State.PUSH_UP に変更しているところです。
当たり判定の実装
当たり判定のロジックは、GObject.js に入っています。2つの矩形の領域が重なっているかどうかを見て、衝突しているかどうかを判定しています。
export function collision(obj1, obj2) {
let flg = obj1.x >= obj2.x + obj2.w
|| obj2.x >= obj1.x + obj1.w
|| obj1.y >= obj2.y + obj2.h
|| obj2.y >= obj1.y + obj1.h;
return !flg;
}
・・・
class GObject {
・・・
hit(src) {
return collision(src, this.bounds());
}
・・・
突き上げの判定処理
床の一部が跳ね上がった時に、Snailがそこにいる場合はSnailも跳ね上がって、ひっくり返ります。Snailに当たったかどうかは、跳ね上がった床が判定します。この場合はPipeオブジェクトです。
先ほどの説明でPipeは跳ね上がった時に、PUSH_UP 状態になると説明しました。PUSH_UP状態のときは action_push_up() メソッドが実行されます。以下のコードです。
action_push_up() {
・・・
this.world.pushUpEnemy(this);
}
pushUpEnemy(src) {
for (let o of this.enemy_list) {
o.push_up(src);
}
}
Worldクラスのメソッドを使って、ゲームに配置された全敵キャラの突き上げ処理をしています。このデモではSnailが一匹だけですが、実際のゲームでは複数同時に動いています。それぞれの敵キャラのpush_up()が呼ばれます。
push_up()で何をしているのかは、Snailの実装を見てみましょう。
push_up(src) {
if (this.state == State.BOUNCE_UP || this.state == State.RECOVER_UP)
return false;
// super.hit関数では当たり判定を行っている
if (super.hit(src, this)) {
// ひっくり返った状態で当てられたかどうかをみて、次の状態を決めている
if (this.state == State.UPSIDEDOWN) {
this.change_state(State.RECOVER_UP);
} else {
this.change_state(State.BOUNCE_UP);
}
// 下から当てたれたブロックと自身の位置によって、跳ねる方向vxを決める
const p1 = src.x+src.w/2;
const p2 = this.x+this.w/2;
if (p1 < p2-2) {
this.vx = 0.5;
} else if (p1 > p2+2) {
this.vx = -0.5;
} else {
this.vx = 0;
}
this.vy = -2;
return true;
}
return false;
}
少しコードが長いですが、読むとわかるように、やっていることはシンプルです。
さて、動かないBlockではpush_up()はどうするのでしょうか?答えは「何もしない」です。
push_up(src) {
}
地震を起こすPower Block
PowerBlockはゲームギミックとしては超強力です。一発逆転のスパイスとして機能するし、演出としても派手です。とはいえ、実はこのギミックの実現は割と簡単です。
すべての敵キャラをひっくり返す
プレイヤーキャラクターがジャンプしてPowerBlockに当たった時に、WorldのpushAllEnemy()メソッドが呼ばれます。以下のその実装です。
pushUpAllEnemy() {
shake_camera();
for (let o of this.enemy_list) {
if (o.is_on_obj(1))
o.push_up(o);
}
}
shake_camera()が画面全体を揺らし。すべての敵キャラに対して、地面に立っているかどうかチェックして、push_up() を呼ぶだけです。
画面を揺らす
画面を揺らすのはどうやっているのでしょうか?先ほどのpushUpAllEnemy() の中で呼んでいたshake_camera()で実現しています。
画面を揺らすために前準備が必要です。これまで作ってきたゲームはすべて、2面のcanvasを使っています。実際のゲーム画面のサイズはかなり小さく、そのままでは遊べないので拡大表示しています。その際には標準関数のdrawImage()を使用しますが、この関数では拡大元のcanvasの切り取る領域を指定できます。
拡大元のcanvasの切り取る領域を描画領域よりも小さめにします。逆に言えば、実際のゲーム画面よりも少し大きな領域を描画領域として用意します。下記の図のイメージです。
画面を揺らす時は、この切り取る枠の位置を変えてあげれば良い、ということです。以下の図のイメージの通りです。
画面が上下にスムーズに揺れるにはフレームごとに、位置を少しずつ変える必要があります。これも一種のアニメーションと言って良いでしょう。実装は index.js の下記の部分です。
・・・
const camera_pos = {
x: 8,
y: 8*2,
};
// camera shake functions
//
let camera_up_count = 0;
let camera_down_count = 0;
const SHAKE_RANGE = 8;
export function shake_camera() {
camera_up_count = SHAKE_RANGE;
}
function check_camera_shake() {
if (camera_up_count > 0) {
camera_up_count--;
let dy = (SHAKE_RANGE-camera_up_count);
camera_pos.y = 8*2 + dy;
if (camera_up_count == 0)
camera_down_count = SHAKE_RANGE;
}
if (camera_down_count > 0) {
camera_down_count--;
let dy = camera_down_count;
camera_pos.y = 8*2 + dy;
}
}
・・・
切り出し枠のy座標をフレームごとに +1して 8ドッド分まで行い、そのあとフレームごとに-1してもとに戻す。ということをしているだけです。
まとめ
さて、今回のデモで、マリオに出てくる基本的なゲームギミックを実現しました。あとは敵キャラの種類を増やすとか、コインとか、得点の概念やステージクリアの導入です。
未完のこのデモのレベルでも、ゲームの面白さの本質の部分はかなり味わえるかと思います。
次回は残りの要素を追加して、マリオ風ゲームとして完成させたいと思います。
コメント