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

ゲーム

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

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

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

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

完成したゲーム

ロードランナー風のアクションパズルゲームの完成です。

青いモンスターを避けて、ステージ上のコインを全部集めてください。隠れ梯子が出ますので、上端まで登るとステージクリアです。なお、ステージはひとつしかありません。

操作方法:
s … ゲーム開始
x … 左掘る、c … 右掘る
j … 上移動、m … 下移動、j … 左移動、l … 右移動
r … ステージ内で最初からやり直す

先に読んでおく記事

ソースコード

GitHub - yenshan/treasure_and_monster
Contribute to yenshan/treasure_and_monster development by creating an account on GitHub.

前回からの変化点

前回までのデモから、以下のが追加されています。

  • コインの追加と関連する要素
    • プレイヤーがコインを取る処理
    • モンスターがコインを取る処理、穴に落ちたらコインを落とす処理
    • プレイヤーがコインを全部集めたら、隠れハシゴが現れるロジック
  • モンスターがブロックに埋まったら死ぬ処理
  • プレイヤーがモンスターに触れたら、ゲームオーバーになる処理
  • プレイヤーが隠れハシゴの上端にあがったら、ステージクリアになる処理
  • タイトル画面
  • ゲームクリア画面
  • ゲームの状態管理と画面遷移

コインの追加と関連するゲーム要素

マップの要素としてコインを新たに追加しています。

const Elem = {
    ...
    COIN: 5,
    ...
}

数字としては5で定義されています。index.jsで定義してる map データを見てみましょう。

const map = [
  //1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6
    1,6,0,0,0,0,0,0,0,0,0,0,0,0,0,1, // 1
    1,6,0,0,0,0,0,0,0,0,0,0,0,0,0,1, // 2
    1,6,0,0,0,0,0,0,0,3,3,3,3,3,3,1, // 3
    1,1,1,1,1,1,1,1,2,0,0,0,0,0,5,1, // 4
    1,0,3,3,3,3,0,0,2,0,5,0,0,7,0,1, // 5
    1,2,0,2,0,2,1,0,2,1,1,1,1,1,1,1, // 6
    1,5,1,5,1,5,1,0,2,1,0,0,0,0,0,1, // 7
    1,1,1,1,1,1,1,0,2,1,0,1,1,1,0,1, // 8
    1,1,5,5,5,1,0,2,1,1,0,1,0,0,0,1, // 9
    1,1,1,1,1,1,0,2,1,0,5,1,2,1,1,1, // 10
    1,0,0,3,3,3,0,2,1,0,1,1,2,1,5,1, // 11
    1,2,1,5,5,5,1,2,0,5,1,1,2,1,0,1, // 12
    1,2,4,4,4,4,4,2,0,1,1,1,2,1,1,1, // 13
    1,2,0,0,8,0,0,2,0,0,0,0,2,1,1,1, // 14
    1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, // 15
    4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4, // 16
];

このmap配列は、ゲーム開始時にJavascriptのオブジェクに変換されます。下記のclass ECoinクラスのインスタンスです。

const State = {
    NORMAL: 'NORMAL',
}

const anime_table = {
    NORMAL: { frames: [24,25,26,27], frame_interval: 10},
}

export class ECoin {
    constructor() {
        this.state = State.NORMAL;
        this.anime_index = 0;
        this.frame_count = 0;
        this.sprite = 24;
    }

    can_go_through() { return true; }

    can_up() { return false; }

    can_stand_on() { return false; }

    can_hang() { return false; }

    can_pick_up() { return true; }

    is_dig_hole() { return false; }

    sprite_no() { return this.sprite; }

    update () {
        !this.anime_update();
    }

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

        if (this.anime_index >= frames.length) {
            this.anime_index = 0;
            return false;
        }

        if (this.frame_count >= anime_table[this.state].frame_interval) {
            this.anime_index++;
            this.frame_count = 0;
        }
        this.frame_count++;

        if (this.anime_index < frames.length) {
            this.sprite = frames[this.anime_index];
        }

        return true;
    }
}

コインはくるくる回るようにしているので、アニメーション処理しています。(anime_table, とanime_update()を参照してください)

コインも含めたマップ上の構成物はclass Worldのコンストラクタで生成してますが、その際にコインの数を数えています。これの数と、プレイヤーが集めたコインの数を比較することで、ゲームクリアの条件を満たしたかを確認しています。

function countCoins(m) {
    let n = 0;
    for (let id of m) {
        if (id == Elem.COIN) n++;
    }
    return n;
}

・・・・

export class World {
    constructor(w,h, data, bg=true) {
        ・・・
        this.num_of_coins = countCoins(data);
        ・・・
    }

プレイヤーがコインを拾う処理

プレイヤーの方は、移動する際にコインと同じ座標になったかどうかをチェックし、コインを拾う処理をしています。

・・・
export class Chara {

    constructor(x,y, world) {
     ・・・
        this.hold_coins = 0;
    }

    update() {
     ・・・

        if (!this[action_func]()) {
            this.check_get_coin();
       ・・・
   }

  ・・・
    check_get_coin() {
        if (this.world.isOnCoin(this.x, this.y)) {
            this.world.pickUp(this.x,this.y);
            this.hold_coins++;
            if (this.hold_coins == this.world.numOfCoins()) {
                this.world.showHideLadder();
            }
        }
    }
・・・

check_get_coin()関数の実装を見てみましょう。

コインを拾う処理は、具体的には、マップ上のコインオブジェクトを削除(this.world.pickUp())して、自分の hold_coins の値を加算する処理(this.hold_coins++)になります。

コインを拾うたびに、自分の保持しているコイン数と全部のコインの数と一致するかどうか確認します(this.world.numOfCoins())。

一致したら、隠れハシゴを表示します(this.world.showHideLadder())。

モンスターがコインを拾う処理、落とす処理

モンスターもコインを拾います。ただし、一つのみです。(この要素が結構ゲームとしてスパイスになっていると思います)。やっているのはclass Charaとほぼ一緒です。

    update() {
       ・・・

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

        if (!this[action_func]()) {
            this.check_get_coin();
            if (this.can_fall()) {
  ・・・
    }
・・・
    check_get_coin() {
        if (this.hold_coins > 0)
            return;
        super.check_get_coin();
    }

check_get_coin() では自分が持っているコインが0よりも多かったら、もう拾わないようにしています。そのあとは super.get_check_coin() を呼んでいます。これは class Charaのget_check_coin()になります。class Enemyがclass Charaを継承していることを思い出してください。親クラスの同名関数の呼び出しは、super.関数名() になります。

青いモンスターはプレイヤーが掘った穴に落ちたら、コインを自分の上に落とします。この実装は下記の部分です。とてもシンプルなので説明は不要でしょう。

    can_fall() {
        if (this.world.isDigHole(this.x, this.y)) {
            this.change_state(State.IN_HOLE);
            if (this.hold_coins > 0) {
                this.world.putCoin(this.x, this.y-1);
                this.hold_coins--;
            }
            return false;
        }
        return super.can_fall()
    }

モンスターの死ぬ処理

モンスターは穴に落ちて、ブロックが改善に回復しても抜け出していない時に死にます。

実装としてはシンプルで、下記のif (this.check_dead() { … } の処理とおりです。

    check_dead() {
        return !this.world.canGoThrough(this.x,this.y);
    }
・・・
    action_in_hole() {
        let ret = this.count_move(0, 0);
        if (!ret) {
            this.change_state(State.UP_HOLE);
        }
        if (this.check_dead()) {
            this.change_state(State.DEADING);
        }
        return true;
    }
・・・

この実装だと、モンスターが穴から抜け出している途中の状態で、ブロックが完全に回復した場合はセーフになります。(ここはゲームのシステムのさじ加減で、もし変更したい場合は action_up_hole() の中でも、check_dead()をすれば良いだけです。)

プレイヤーキャラクターの死ぬ処理

プレイヤーのキャラクターは次の条件で死にます。

・モンスターに触れられた場合
・穴に入ったままブロックが完全に回復して埋まった場合

この2つの状態が検出されたら、プレイヤーのDEAD状態アニメーションをして、その後タイトル画面に戻しています。ここの実装は下記の部分になります。

・・・
    update() {
        if (this.check_dead()) {
            this.change_state(State.DEADING);
        }
     ・・・
    }

    check_dead() {
        if (this.state == State.DEADING || this.state == State.DEAD)
            return false;
        if (this.world.isOnEnemy(this.x, this.y, this.w, this.h)) {
            return true;
        }
        if (!this.world.canGoThrough(this.x, this.y)) {
            return true;
        }
        return false;
    }
・・・

これ以外にも、空間に閉じ込められてゲームクリアできない状態になることがあります。

閉じ込められて動きとれない場合

パズル部分のブロックの消す順番を間違えると、こうなります。この場合はステージリセットの手段を用意しています。(このゲームだと”r”キー)

タイトル画面とクリア画面

ゲーム中の状態 (State.GAME) 以外で、以下の2つを特別な状態として追加しています。

・タイトル画面 (State.TITLE)
・ゲームクリア画面 (State.GAME_CLEAR)

どんなにシンプルなレトロゲームでも、この2つはあります。タイトル画面はゲームの玄関といえるものなので、ワクワクするような雰囲気を出したいですし、ゲームクリア画面は達成感が味わえるような演出をしたいものです。

このゲームでは最小限の実装で、それなりに雰囲気のある画面の演出を実現しています。

タイトル画面

プレイヤーキャラクターは止まってアニメーションしていて、モンスターは右端から歩いてプレイヤーに向かって、止まった後もアニメーションしています。

このシーンために特別な実装はしていません。ゲームエンジンをそのまま使えるように、この画面用にマップを用意して、ブロック配置を工夫してこのようなシチュエーションを作っているだけです。

あとは、タイトルの画像データと”Press ‘S’ Key to Start”文字を上に重ねて描画しています。

ゲームクリア画面

キャラクターとコインを並べてアニメーションさせているだけの画面ですが、例えば”Game Clear”文字だけを表示するのと比べたら、だいぶマシな感じだと思います。

ここは少し工夫をしています。この画面に専用のマップを用意して配置しているのはタイトル画面と一緒です。が、実はプレイヤーキャラクターは、このままゲームエンジンを実行すると落下してしまいます。

それを防ぐために、キャラクターの下に見えないブロックを入れています。ゲームの要素にない透明なブロックを用意したというのがミソです。透明なブロックは、アイディアしだいではゲームの要素として活かすことも可能なので、純粋に無駄ではないでしょう。

以上のようなところで、これらの画面に専用のコードを書くよりは、ちょっとしたアイディアで同じことをミニマムなロジックで実現することに筆者は喜びを感じます。

最後に

これまで5回に分けて、ロードランナー風のアクションパズルゲームを作ってきました。プレイしてみて気づくと思いますが、いわゆるバグに近いアラがあります。

・ハシゴの下のブロックも消せる
・バーにぶら下がっているときに、ブロックを消すと、プレイヤーが立ってしまう
・レーザだけ残してプレイヤーが移動できてしまう

他にも何か見つかるかもしれません。ここら辺は直そうと思えば直せるのですが、やらないのは面倒くさいだけです。

気に入った方は、ソースを公開していますので、暇つぶしに修正してみてください。


コメント

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