JavaScriptで2Dドット絵ジャンプアクションゲームを作ろう(その1)〜マリオに愛を込めて

ゲーム

ゲームエンジンやライブラリを使わずに、HTMLとJavaScriptだけでゲームを作ります。

以下のような人のためにあります。

ゲームを作ってみたいけど、何をどうしていいか分からない。
単純なゲームを作りたいだけなのに、UnityとかUnreal Engineは面倒くさい。

ある程度プログラミングの知識がある人を対象にしています。

何を作るのか?

これから何回かの記事を通して「マリオブラザーズ」風のゲームを作っていきます。スーパがつかない1画面のアクションゲームの方です。筆者は個人的にスーパーマリオよりもこちらの方が好きでした。

シンプルで奥深いから、というのもあるし、このゲームの面白さの本領は二人プレイの時で、スーパマリオでは味わえません。とはいえ、このシリーズでは一人でプレイできる段階までのものを作っていきます。

動作デモ

この記事では、マリオの世界で有名なゲーム・ギミック、「床の突き上げ」を実現します。

サイバーパンク風の世界観にしてみました。とりあえずデモを遊んでみてください。

j … 左に移動, l … 右に移動, c … ジャンプ
ジョイパッドでも操作できます。ゲーム画面をダブルクリックするとフルスクリーンになるよ。

先に読んでおく記事

キャラクターのジャンプ・アクションの実現方法は下記記事を読んでください。

ソースコード

gamedev_javascript/jump_action_floor_bounce_demo at main ?? yenshan/gamedev_javascript
Contribute to yenshan/gamedev_javascript development by creating an account on GitHub.

ファイルの構成は下記のとおりです。

.
├── Chara.js
├── README.md
├── SpriteSheet.js
├── UserInput.js
├── World.js
├── assets
│   ├── background_picture.png
│   ├── chara_spritesheet.png
│   ├── stages.json
│   ├── style.css
│   └── tilesheet.png
├── index.html
└── index.js

マップの構成物のデータの持ち方

これまでゲームマップの構成物は動かない前提のものだったので、座標の情報を持つことはありませんでした。今回は構成物が動くので、物体自体に座標を持たせた方が実装としてはシンプルになります。

このデモのマップ上の構成物は、プレイヤーを除くと2種類で、動く床と動かない床です。

const Elem = {
 ・・・
    PIPE: 2,      // 動く床(ゲームの世界観ではパイプとする)
    BLOCK: 3,  // 動かない床
}

ゲームマップの管理はWorld.jsで行っていますが、初期化時にコンストラクタに渡されたマップデータを使って、createMap()で対応するオブジェクトのリストを生成します。このとき生成するオブジェクトに画面上の座標情報を渡します。

以下がその実装です。

export class World {
    constructor(w,h, data) {
        this.w = w;
        this.h = h;
        this.map = this.createMap(data);
        ・・・
    }

    createMap(m) {
        let dat = [];
        for (let y = 0; y < this.h; y++) {
            for (let x = 0; x < this.w; x++) {
                let id = m[x + y*this.w];
                switch(id) {
                case Elem.PIPE: dat.push(new EPipe(x*MAP_ELEM_SIZE, y*MAP_ELEM_SIZE)); break;
                case Elem.BLOCK: dat.push(new EBlock(x*MAP_ELEM_SIZE, y*MAP_ELEM_SIZE)); break;
                }
            }
        }
        return dat;
    }

createMap()でやっているのは、x座標、y座標を使って、それぞれマップの幅 w、高さ hまで、一つずつのマスの座標の該当するオブジェクトのリストを作成することです。

オブジェクトを生成する際に、そのオブジェクトの座標をコンストラクタに渡しています。

何もない空間はオブジェクトは生成されません。これは、これまで作ってきたゲームで使ってきたデータ構造と違う部分です。

2種類のデータ構造の良し悪し

以下に2種類のマップの構成物の管理方法について示します。

今回の座標管理の方法はBです。こちらの方が柔軟性があり、いろんなタイプのゲームに対応できます(ブロック崩し、シューティングゲーム、アクションゲームなど)。

では、管理方法Aにメリットはないのかというと、そんなことはありません。管理方法Aの方が当たり判定の時に計算量が少ないです(その代わりメモリは使用します)。

管理方法Aは、プレイヤーの座標からindexを作って配列からオブジェクトを引けば良いのに対して、管理方法Bは、全てのオブジェクトを順に座標を比較チェックする必要があります。

しかし、管理方法Aでは、今回実現したい床の突き上げは実装が複雑になります。実現できないとは言いませんが、当たった場所のブロックの座標に、動くブロックを別に用意して重ねながら、元のブロックは表示を消すなど、トリッキーな仕組みが必要です。

管理方法Bは汎用性があり、どのタイプのゲームでも問題ないですが、ロードランナーのようなゲームでプレイヤーのいる位置がハシゴかどうかを確認するのに、いちいち全構成物をなめてチェックするのはさすがに無駄です。

まあ、メモリを使うとか計算量が多いとか言っても、所詮レトロゲームの小さな世界は、今のコンピュータからすると微々たるものです。8ビット時代の貧弱なPCを使っているわけではないので、ここは個人の好みに収まる範囲ですが、プログラマーとしては、処理速度/メモリ使用量/コードの見通しの良さ、を天秤にかけながらどの設計・実装を選択するか決めたいものです。

最終的に実装のシンプルさを選んで、メモリ/処理速度をまったく気にしない富豪プログラマー的な実装方法を選択するのは全然ありだと思います。今の時代でレトロゲームを実装する醍醐味のひとつです。まあ、Unity/Unreal Engine などを使って実現する場合と比べたら、笑ってしまうくらい大した無駄ではないですし。

床との当たり判定、突き上げの動作の実現

プレイヤーがジャンプした時に、上のブロックに衝突したかどうかを判断します。具体的には、CharaのstateがJUMP_UP時の処理関数 action_jump_up() に実装されています。

    action_jump_up() {
        if (this.vy < 0) {
            const hl = this.world.hitObj(this.x+2, this.y+9, 0, this.vy/4);
            const hc = this.world.hitObj(this.x+this.w/2, this.y+9, 0, this.vy/2);
            const hr = this.world.hitObj(this.x+this.w-2, this.y+9, 0, this.vy/4);
            if (hl || hc || hr) {
                this.vy = 0;
                this.change_state(State.JUMP_HIT);
                return;
            }
        }
    ・・・・
    }

Charaの座標と幅の情報を元に、上方向のブロックに当たっているか、WorldクラスのhitObj()メソッドを使って判断します。このデモの実装では、Charaの幅16ドットの領域のうち、どこに当たったかで築き上げる力を変えています。(あくまで実装例のひとつなので、自分の好みのよっていろいろ変えてみてください。)

さて、WorldクラスのhitObj()は、マップ上の全オブジェクトのhit() メソッドを呼んで確認します。

    hitObj(x,y,dx,dy) {
        for (let o of this.map) {
            if (o.hit(x,y, dx, dy)) {
                return true;
            }
        }
        return false;
    }
class EPipe {
   ・・・
    hit(x,y, dx, dy) {
        if (!this.can_go_through(x,y)) {
            this.x += dx;
            this.vy = dy;
            this.pushed_up = true;
            return true;
        }
        return false;
    }
・・・

当たった場合は、そのオブジェクトは引数で渡された dy を使って vy とし、次のフレームからy座標の移動をします。オブジェクトのマイフレームの動きはupdate()関数で処理します。

class EPipe {
・・・
    update() {
        if (this.pushed_up) {
            this.y += this.vy;
            this.vy += GRAVITY;
            if (this.vy >= 0) {
                if (Math.floor(this.y) == this.o_y) {
                    this.y = this.o_y;
                    this.pushed_up = false;
                } 
            }
        }
・・・

上記の実装を見れば分かるように、突き上がった状態(push_upフラグがtrue)なら、上方向に加速度が働いている状態です。

このときだけ毎フレーム vy に GRAVITY(重力) を加えます。そうするとて跳ね上がって落ちる感じの動きをします。ここの処理はプレイヤーの重力処理と一緒です。

この重力の処理は、EPipeクラス内ではなくWorld クラスでCharaの重力処理と一緒にすると、実装のまとまりがあって美しいと思います。が、ひとまずこれでよしとします。

まとめ

マリオの特徴的なゲームギミックである「床の突き上げ」を実現したデモの実装について解説しました。Youtubeにある動画をを観るとわかりますが、オリジナルのマリオは別のやり方で実現しています。

このデモでは、まずはシンプルな方法で実現するようにしました。この記事の解説を読んで、驚くほど実装が簡単だと感じだと思います。

最終的にゲームをどのように仕上げるかで、ここのギミックもまた変化していくと思います。作りながら変わっていく様がまた楽しいのです。

さて、次回は床の突き上げによってひっくり返る敵キャラを登場させたいと思います。

コメント

タイトルとURLをコピーしました