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

ゲーム

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

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

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

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

この記事で作るもの

敵キャラである水色モンスターを導入します。

水色のモンスターがどこまでも追いかけてきます。捕まってもゲームオーパになることはありませんので、色々逃げてみてください。ブロックをレーザーで消せます。ブロックはしばらくすると自動修復します。

操作方法:
s … ゲーム開始
x … 左掘る、c … 右掘る
j … 上移動、m … 下移動、j … 左移動、l … 右移動
r … ゲームリセット

先に読んでおく記事

ソースコード

how-to-make-lode-runnder-like-game/03_chara_w_monster 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.

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

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

前回からはEnemy.jsファイルが増えています。今回は主にEnemy.jsを解説します。

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

const STAY_HOLE_SEC = 3;

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',
    IN_HOLE: 'IN_HOLE',
    UP_HOLE: 'UP_HOLE',
}

const anime_table =  {
    STOP: {move_count: 0, frames: [50,51], frame_interval: 60},
    MOVE_LEFT: {move_count: 8, frames: [50,52,53], frame_interval: 3},
    MOVE_RIGHT: { move_count: 8, frames: [50,52,53], frame_interval: 3},
    MOVE_UP: {move_count: 8, frames: [55,56], frame_interval: -1},
    MOVE_DOWN: {move_count: 8, frames: [55,56], frame_interval: -1},
    FALL: {move_count: 8, frames: [49,56], frame_interval: 2},
    STOP_LADDER: {move_count: 8, frames: [55,56], frame_interval: -1},
    MOVE_BAR_LEFT: {move_count: 8, frames: [48,49], frame_interval: -1},
    MOVE_BAR_RIGHT: {move_count: 8, frames: [48,49], frame_interval: -1},
    STOP_BAR: {move_count: 8, frames: [48], frame_interval: -1},
    IN_HOLE: {move_count: 30*STAY_HOLE_SEC, frames: [50], frame_interval: 1},
    UP_HOLE: {move_count: 8, frames: [50,52], frame_interval: 1},
};

export class Enemy extends Chara {

    constructor(x,y, map, chara) {
        super(x,y,map);
        this.sprite = 50;
        this.anime_table = anime_table;
        this.chara = chara;
        this.action_interval = 1;
        this.action_wait_count = 0;
    }

    wait_for_action() {
        if (this.action_wait_count < this.action_interval) {
            this.action_wait_count++;
            return true;
        }
        this.action_wait_count = 0;
        return false;
    }
    
    update() {
        if (this.wait_for_action())
            return;

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

        if (!this[action_func]()) {
            if (this.can_fall()) {
                this.change_state(State.FALL);
            } else {
                this.think_next_action();
            }
        }

        this.anime_update();
    }

    think_next_action() {
        let chara = this.chara;

        if (chara.y == this.y) {
            if (chara.x < this.x) {
                this.check_move_left();
            } else {
                this.check_move_right();
            }
            return;
        }

        if (chara.y > this.y) { 
            if (this.check_move_down()) return;
        } else {
            if (this.check_move_up()) return;
        }

        switch(this.state) {
        case State.MOVE_LEFT:
            if (!this.check_move_left()) this.check_move_right();
            break;
        case State.MOVE_RIGHT:
            if (!this.check_move_right()) this.check_move_left();
            break;
        default:
            if (chara.x < this.x) {
                this.check_move_left();
            } else {
                this.check_move_right();
            }
        }

    }

    check_move_down() {
        if (this.map.isDigHole(this.x, this.y))
            return false;
        return super.check_move_down();
    }

    can_fall() {
        if (this.map.isDigHole(this.x, this.y)) {
            this.change_state(State.IN_HOLE);
            return false;
        }
        return super.can_fall()
    }

    action_in_hole() {
        let ret = this.count_move(0, 0);
        if (!ret) {
            // 動作完了したら穴を上る
            this.change_state(State.UP_HOLE);
        }
        return true;
    }

    action_up_hole() {
        let ret = this.count_move(0, -1);
        if (!ret) {
            // 動作完了したらCharaの位置を確認して移動する
            if (this.chara.x < this.x) {
                this.change_state(State.MOVE_LEFT);
            } else {
                this.change_state(State.MOVE_RIGHT);
            }
        }
        return true;
    }
}

CharaとEnemyクラスの継承関係

水色のモンスターはclass Enemyで実現しています。Enemyがゲーム内のフィールドでできる行動はプレイヤーとほぼ同じです。

違うのは、「プレイヤーの掘った穴は通り抜けない(ハマる)」という点だけです。

実装内容は class Charaとほぼ同じです。この時にclass Charaをまるごとコピーしてclass Enemyを作って変更するという手もあります。

が、同じ実装コードはソースコードの管理上、できるだけ一箇所だけにしたいところです。そこでオブジェクト指向言語JavaScriptの継承機能を使います。

継承の実装方法は extends を使います。

class Enemy extends Chara {
・・・

継承関係を図にすると以下のようになります。

EnemyはCharaを継承しているので、Charaの機能はそのまま使用できます。その上で、Charaとの差分の部分だけを追加で実装すれば良いのです。

本来なら、設計的にはプレイヤーのCharaクラスとEnemyクラスの共通部分だけを抽出して、別の親クラスを作って継承関係とすべきです。というのもCharaクラスにはEnemyクラスに必要のない、プレイヤーのキー入力用メソッドが定義されているからです。

とはいえ、このゲームはこれ以上登場キャラは増えないし、厳密にクラス分けして管理するメリットもないので、このままとしています。

プレイヤーを自動追尾するロジック

今回のデモで作ったフィールドだと、水色モンスターはプレイヤーをどこまでも追いかけていけます。なかなか悪くない動きだと思います。

実際にやっているのは、動きながらプレイヤーの方向に近づくように動作の状態を決めることです。

下記の関数内で、モンスターが次にどの方向に動くかを判断しています。

    think_next_action() {
   ・・・
    }

モンスターの思考ルーチンは、下記のとおりシンプルなものです。シンプル過ぎて拍子抜けするかもしれません。

・プレイヤーと同じ高さの場合は、プレイヤーの方向に動きます。
・プレイヤーと違う高さの場合、プレイヤーのいる方の上下に移動できるかチェックし、移動できる場合は移動します。
・それ以外の場合は、今の方向に行き止まりになるまで動き、行き止まりに当たったら反対方向に動きます

このアルゴリズムだと、プレイヤーと同じ高さで壁を挟んで対峙した場合は、水色モンスターは壁の向こう側に停滞する感じになると思います。

やろうと思えば、もっと賢くは作れるとは思いますが、ゲームが難しくなる可能性があります。いろいろなマップで試してみて、違和感がでるようなら調整すれば良いかなと思います。

穴に落ちて抜け出す動作

敵キャラがプレイヤーと動作が違う部分は、「プレイヤーの掘った穴は通り抜けない(ハマる)」という点です。穴にハマった後、しばらく時間が経って自力で脱出します。

以下の2つの状態を作る必要があります。
・穴にハマっている状態 (State.IN_HOLE)
・穴から上がる状態 (State.UP_HOLE)

状態遷移は下記の図のようになります。

実装では、これを実現するための状態を追加し、対応するアニメーションも定義します。

const State = {
    ・・・
    IN_HOLE: 'IN_HOLE',
    UP_HOLE: 'UP_HOLE',
}

const anime_table =  {
  ・・・
    IN_HOLE: {move_count: 30*STAY_HOLE_SEC, frames: [50], frame_interval: 1},
    UP_HOLE: {move_count: 8, frames: [50,52], frame_interval: 1},
};

それぞれの状態の動作関数は下記のとおりです。

    action_in_hole() {
        let ret = this.count_move(0, 0);
        if (!ret) {
            // 動作完了したら穴を上る
            this.change_state(State.UP_HOLE);
        }
        return true; // can_fall()チェックせずに直接次の状態のactionに移るためtrueとする
    }

    action_up_hole() {
        let ret = this.count_move(0, -1);
        if (!ret) {
            // 動作完了したらCharaの位置を確認して移動する
            if (this.chara.x < this.x) {
                this.change_state(State.MOVE_LEFT);
            } else {
                this.change_state(State.MOVE_RIGHT);
            }
        }
        return true; // can_fall()チェックせずに直接次の状態のactionに移るためtrueとする
    }

次回は

さて、ついに敵キャラも導入しました。あと、残る要素は「金塊」です。

次回は、プレイヤーがフィールド上にある金塊を集めて、ゲームクリアするところまでを実装します。敵キャラに触れられたらゲームオーバーになる要素も追加します。

次回はいよいよゲームの完成です。


コメント

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