アクションパズルゲームの作り方 – ロードランナーにインスパイヤされて(その1)

ゲーム

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

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

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

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

ロードランナーはどういうゲームか?

ロードランナーは1980年代に一世風靡したアクションパズルゲームです。当時の8bitパソコンはもちろん、ファミコンにも移植されたました。今でも最新ゲーム機にリメイクされている古典的名作です。

ゲームの目的はステージにある金塊を敵に捕まらずに回収し脱出すること。金塊を全て回収した状態でステージ最上部に達すればステージクリアとなります。

このYoutube動画を見ると、PC /コンソール/アーケード/モバイルなど、ほぼ全てのプラットホームに移植されたんじゃないかと思いますね。

ゲームプログラミングの題材として

難易度的には、倉庫版やテトリスっぽいものが作れたら、少し背伸びするくらいの高さです。個人が趣味で楽しく作れる範囲としてはギリギリの大きさかな、と思います。

これ以上大きなゲームは何かしらの面倒さがともなうので、それに打ち勝つモチベーションが別に必要です(個人の感想ですw)。

筆者はこれまで3回くらいロードランナーもどきを作っていますが、最後に作ったものがわりかし見た目もきれいなので、これを何回かに分けて作り直しながら過程を解説して行きたいと思います。

筆者作のロードランナーもどき

この記事で作るもの

レンガ、ハシゴ、バーなどの画面の構成物とプレイヤーの移動部分を実装します。
下記キーを押してみてください。
i … 上
m … 下
j … 左
l … 右

先に読んでおく記事

ドット絵をアニメーションさせる方法と、キャラクターの状態管理に連動させる方法は、別の記事に書いていますので、先に読んでおくことをお勧めします。

ソースコード

how-to-make-lode-runnder-like-game/01_chara_and_world_base at main ?? yenshan/how-to-make-lode-runnder-like-game
Contribute to yenshan/how-to-make-lode-runnder-like-game development by creating an account on GitHub.

このデモのソースコードのファイル構成は下記のとおりです。

.
├── Chara.js
├── World.js
├── index.html
├── index.js
└── spritesheet.png

この記事ではキャラクターの状態管理、動作の実装を説明します。Chara.jsの全ソースは下記のとおりです。

import {drawSprite} from "./index.js"

const State = {
    STOP: 'STOP',
    MOVE_LEFT : 'MOVE_LEFT',
    MOVE_RIGHT: 'MOVE_RIGHT',
    MOVE_UP : 'MOVE_UP',
    MOVE_DOWN : 'MOVE_DOWN',
    FALL:  'FALL',
    STOP_LADDER: 'STOP_LADDER',
    MOVE_BAR_LEFT : 'MOVE_BAR_LEFT',
    MOVE_BAR_RIGHT : 'MOVE_BAR_RIGHT',
    STOP_BAR : 'STOP_BAR',
}

const anime_table =  {
    STOP: {move_count: 0, frames: [34,35], frame_interval: 60},
    MOVE_LEFT: {move_count: 8, frames: [36,37,38], frame_interval: 3},
    MOVE_RIGHT: { move_count: 8, frames: [36,37,38], frame_interval: 3},
    MOVE_UP: {move_count: 8, frames: [39,40], frame_interval: -1},
    MOVE_DOWN: {move_count: 8, frames: [39,40], frame_interval: -1},
    FALL: {move_count: 8, frames: [34,34,41,41], frame_interval: 2},
    STOP_LADDER: {move_count: 8, frames: [39,40], frame_interval: -1},
    MOVE_BAR_LEFT: {move_count: 8, frames: [32,33], frame_interval: -1},
    MOVE_BAR_RIGHT: {move_count: 8, frames: [32,33], frame_interval: -1},
    STOP_BAR: {move_count: 8, frames: [32,33], frame_interval: -1},
};

const Move = {
    NONE: 0,
    STOP: 1,
    RIGHT: 2,
    LEFT: 3,
    DOWN: 4,
    UP: 5,
}

export class Chara {

    constructor(x,y, map) {
        this.x = x;
        this.y = y;
        this.w = 8;
        this.h = 8;
        this.anime_count = 0;
        this.anime_index = 0;
        this.move_count = 0;
        this.state = State.STOP;
        this.request_move = Move.STOP;
        this.flip = false;
        this.anime_table = anime_table;
        this.map = map;
    }

    update() {
        let action_func = `action_${this.state.toLowerCase()}`;

        if (!this[action_func]()) {
            if (this.can_fall()) {
                this.change_state(State.FALL);
            } else {
                switch(this.request_move) {
                case Move.STOP: this.check_move_stop(); break;
                case Move.RIGHT: this.check_move_right(); break;
                case Move.LEFT: this.check_move_left(); break;
                case Move.UP: this.check_move_up(); break;
                case Move.DOWN: this.check_move_down(); break;
                }
                this.request_move = Move.STOP;
            }
        }

        this.anime_update();
    }

    check_move_stop() {
        if (this.is_over_ladder()) {
            this.change_state(State.STOP_LADDER);
        } else if (this.is_over_bar()) {
            this.change_state(State.STOP_BAR);
        } else {
            this.change_state(State.STOP);
        }
    }

    check_move_right() {
        if (this.map.isHitWall(this.x+this.w+1, this.y))
            return;
        if (this.can_fall())
            return

        if (this.map.isOnBar(this.x+this.w+1, this.y)) {
            this.change_state(State.MOVE_BAR_RIGHT);
            this.anime_update(true);
        } else {
            this.change_state(State.MOVE_RIGHT);
        }
        this.flip = false;
    }

    check_move_left() {
        if (this.map.isHitWall(this.x-1, this.y))
            return;
        if (this.can_fall())
            return

        if (this.map.isOnBar(this.x-1, this.y)) {
            this.change_state(State.MOVE_BAR_LEFT);
            this.anime_update(true);
        } else {
            this.change_state(State.MOVE_LEFT);
        }
        this.flip = true;
    }

    check_move_up() {
        if (!this.is_over_ladder()) 
            return

        this.change_state(State.MOVE_UP);
        this.anime_update(true);
    }

    check_move_down() {
        if (this.is_on_wall())
            return;
        if (!this.is_on_ladder() && !this.is_over_ladder() && !this.is_over_bar())
            return;
        this.change_state(State.MOVE_DOWN);
        this.anime_update(true);
    }

    action_stop() {
    }

    action_move_left() {
        return this.count_move(-1, 0);
    }

    action_move_right() {
        return this.count_move(1, 0);
    }

    action_move_up() {
        return this.count_move(0, -1);
    }

    action_move_down() {
        return this.count_move(0, 1);
    }

    action_stop_ladder() {
        this.action_stop();
    }

    action_fall() {
        return this.count_move(0, 1);
    }

    action_move_bar_right() {
        return this.count_move(1, 0);
    }

    action_move_bar_left() {
        return this.count_move(-1, 0);
    }

    action_stop_bar() {
        return this.action_stop();
    }


    anime_update(force=false) {
        let frames = this.anime_table[this.state].frames;
        let frame_interval = this.anime_table[this.state].frame_interval;

        if (force) {
            this.anime_index++;
        } else if (frame_interval == -1) {
        } else if (this.anime_count >= frame_interval) {
            this.anime_index++;
            this.anime_count = 0;
        }

        if (this.anime_index >= frames.length)
            this.anime_index = 0;

        this.sprite = frames[this.anime_index];
        this.anime_count++;
    }

    draw() {
        drawSprite(this.sprite, this.x, this.y, this.flip);
    }

    change_state(state) {
        this.state = state;
        this.move_count = this.anime_table[this.state].move_count;

        if (this.state != state) {
            this.anime_index = 0; 
            this.anime_count = 0;
        }
    }

    count_move(dx, dy) {
        this.move_count--;
        if (this.move_count < 0) {
            return false; // 動作が終わったらfalseを返す
        }
        this.x += dx;
        this.y += dy;
        return true;
    }

    stop_move() {
        this.request_move = Move.STOP;
    }

    can_fall() {
        return ! (this.is_on_wall() || this.is_on_ladder() || this.is_over_ladder() || this.is_over_bar());
    }

    is_on_wall() {
        return this.map.isHitWall(this.x, this.y+this.h+1);
    }

    is_over_ladder() {
        return this.map.isOnLadder(this.x+this.w/2, this.y+this.h/2);
    }

    is_on_ladder() {
        return this.map.isOnLadder(this.x, this.y+this.h+1);
    }

    is_over_bar() {
        return this.map.isOnBar(this.x+this.w/2, this.y+this.h/2);
    }


    move_right() {
        this.request_move = Move.RIGHT;
    }

    move_left() {
        this.request_move = Move.LEFT;
    }

    move_up() {
        this.request_move = Move.UP;
    }

    move_down() {
        this.request_move = Move.DOWN;
    }

}

キャラクターの状態

今回のデモを実現するための状態は以下の通りです。

スプライトは右向きの絵しか用意しません。キャラクターが左に動くときは、絵を反転させて描画するようにします。

状態の定義とアニメーション情報テーブルの実装コードは下記のとおりです。

const State = {
    STOP: 'STOP',
    MOVE_LEFT : 'MOVE_LEFT',
    MOVE_RIGHT: 'MOVE_RIGHT',
    MOVE_UP : 'MOVE_UP',
    MOVE_DOWN : 'MOVE_DOWN',
    FALL:  'FALL',
    STOP_LADDER: 'STOP_LADDER',
    MOVE_BAR_LEFT : 'MOVE_BAR_LEFT',
    MOVE_BAR_RIGHT : 'MOVE_BAR_RIGHT',
    STOP_BAR : 'STOP_BAR',
}

const anime_table =  {
    STOP: {move_count: 120, frames: [34,35], frame_interval: 60},
    MOVE_LEFT: {move_count: 8, frames: [36,37,38], frame_interval: 3},
    MOVE_RIGHT: { move_count: 8, frames: [36,37,38], frame_interval: 3},
    MOVE_UP: {move_count: 8, frames: [39,40], frame_interval: -1},
    MOVE_DOWN: {move_count: 8, frames: [39,40], frame_interval: -1},
    FALL: {move_count: 8, frames: [34,34,41,41], frame_interval: 2},
    STOP_LADDER: {move_count: 8, frames: [39,40], frame_interval: -1},
    MOVE_BAR_LEFT: {move_count: 8, frames: [32,33], frame_interval: -1},
    MOVE_BAR_RIGHT: {move_count: 8, frames: [32,33], frame_interval: -1},
    STOP_BAR: {move_count: 8, frames: [32,33], frame_interval: -1},
};

Stateでは各状態を文字列として定義しています。

anime_tableオブジェクトは、Stateの文字列をキーにして、対応する値はの各状態のアニメーション情報のオブジェクトです。このanime_table 内の情報はキーから引けるので、定義の順番を気にする必要はありません。

情報の取り出し方は、下記のとおり簡単です。

        let frames = this.anime_table[this.state].frames;
        let frame_interval = this.anime_table[this.state].frame_interval;

状態の切り替えシーケンス

ある状態から別の状態に移るときの処理シーケンスは以下の図のようになります。

キャラの置かれた状況とユーザ入力から次の状態を決める

具体的には以下のことを処理します

  • 落下する状態かどうか?
  • 落下する状態であれば、落下状態に変更する
  • 落下する状態でなければ、ユーザの入力(上下左右ボタン)状態を拾って、次の動作を決める
    • 上下左右それぞれに対して、移動できるかどうかをチェックする
    • それぞれの方向に移動できる状態であれば、状態を変更する

以下のその実装部分です。

    update() {
        let action_func = `action_${this.state.toLowerCase()}`;

        if (!this[action_func]()) {
        // 状態の動作が終了した 
            if (this.can_fall()) {
                this.change_state(State.FALL);
            } else {
                switch(this.request_move) {
                case Move.STOP: this.check_move_stop(); break;
                case Move.RIGHT: this.check_move_right(); break;
                case Move.LEFT: this.check_move_left(); break;
                case Move.UP: this.check_move_up(); break;
                case Move.DOWN: this.check_move_down(); break;
                }
                this.request_move = Move.STOP;
            }
        }

        this.anime_update();
    }

    check_move_stop() {
     //省略
    }

    check_move_right() {
     //省略
    }

    check_move_left() {
     //省略
    }

    check_move_up() {
     //省略
    }

    check_move_down() {
     //省略
    }

ユーザの入力と状態への反映

キャラクターの状態によっては、ユーザの入力を受け付けません。例えば落下中などがそうです。また、ブロック上に立っているときに、下キーを押しても反応しません。

今回の実装では、ユーザ入力はいったん状態変更のリクエストとして受けて、キャラクターの動作がひと段落したときに、そのリクエストをチェックするような作りにしています。

    move_right() {
        this.request_move = Move.RIGHT;
    }

    move_left() {
        this.request_move = Move.LEFT;
    }

    move_up() {
        this.request_move = Move.UP;
    }

    move_down() {
        this.request_move = Move.DOWN;
    }

this.request_move を実際にみているのは、すでに見た update() 関数の下記の部分です。

    update() {
・・・
                switch(this.request_move) {
                case Move.STOP: this.check_move_stop(); break;
                case Move.RIGHT: this.check_move_right(); break;
                case Move.LEFT: this.check_move_left(); break;
                case Move.UP: this.check_move_up(); break;
                case Move.DOWN: this.check_move_down(); break;
                }
                this.request_move = Move.STOP;
・・・
    }

状態ごとの動作関数呼び出し

ある状態にいるときに、その状態でやりたい動作があります。例えば、State.MOVE_RIGHT の場合は右に移動します。State.MOVE_UP は上に移動し、State.MOVE_DOWNは下に移動します。

状態ごとにそれぞれ違う関数を呼びたいのですが、ベタな実装としては以下のようなものがあります。

        switch(this.state) {
        case State.STOP:
            this.action_stop();
            break;
        case State.MOVE_RIGHT:
            this.action_right();
            break;
        case State.MOVE_LEFT:
            this.action_left();
            break;
        }

一方で、switch-case 文を使わずに、JavaScriptではもっと簡潔に実装することが可能です。

Stateは文字列として定義しているので、下記のような実装で関数振り分けが可能です。

const State = {
    STOP: 'STOP',
    MOVE_LEFT : 'MOVE_LEFT',
    MOVE_RIGHT: 'MOVE_RIGHT',        
・・・
}

・・・・
update (){
     let action_func = `action_${this.state.toLowerCase()}`;

       this[action_func]()
・・・
}

action_func変数に関数名を作って代入して、this[関数名]() という記述で、自身のプロパティ参照の形で関数実行しています。

関数名の作成はStateの定義文字列を使って作っています。下記の部分です。

`action_${this.state.toLowerCase()}`

例えば、this.state が State.MOVE_RIGHT のとき、中身は ‘MOVE_RIGHT’ と言う文字列です。
この文字列オブジェクトのtoLowerCase()メソッドを呼んでいるので、小文字に変換されて、’move_right’ という文字列になります。

この先頭にaction_をくっつけているので、最終的には ‘action_move_right’ という文字列になります。これが、this[…]() に渡される関数名になります。

switch-case 文を使った実装と違い、以下のデメリットがあります。

  • 呼び出し先関数名を自由に決められない
  • Stateで定義した状態に対応した関数を必ず定義する必要がある(定義しないと動作中エラーになる)

とはいえ、ほとんどのケースは状態ごとに動作関数は必要で、もし何もしない状態を定義したいなら、空関数を作れば良いだけです。総じて簡潔に実装できるメリットの方が大きいと思います。

次回は

ちょっと長くなったので、いったん区切って終わりとします。次回はマップの表示物の管理と当たり判定について説明したいと思います。




コメント

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