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

ゲーム

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

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

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

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

この記事で作るもの

前回の記事のデモからは、自動修復するブロックのギミックを作成します。

ブロックをレーザーで消せます。ブロックはしばらくすると自動修復します。
x … 左掘る、c … 右掘る
j … 上移動、m … 下移動、j … 左移動、l … 右移動

先に読んでおく記事

ソースコード

how-to-make-lode-runnder-like-game/02_auto_recover_block 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
├── World.js
├── index.html
├── index.js
└── spritesheet.png

前回からは、Block.js ファイルが増えています。World.jsも大幅に変わりました。

const BLOCK_RECOVER_SEC = 7;

const State = {
    NORMAL: 'NORMAL',
    BREAKING: 'BREAKING',
    RECOVER: 'RECOVER',
    BROKEN: 'BROKEN',
}

const anime_table = {
    NORMAL: { frames: [16], frame_interval: 1},
    BREAKING: { frames: [17,18,19,20], frame_interval: 20 },
    RECOVER: { frames: [19,18,17], frame_interval: 60 },
    BROKEN: { frames: [20], frame_interval: 60*BLOCK_RECOVER_SEC },
};

const next_state_table = new Map([
    [State.NORMAL, State.NORMAL],
    [State.BREAKING, State.BROKEN],
    [State.BROKEN, State.RECOVER],
    [State.RECOVER, State.NORMAL],
]);

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

    can_go_through() { return this.state != State.NORMAL; }

    can_up() { return false; }

    can_stand_on() { return this.state == State.NORMAL; }

    can_hang() { return false; }

    sprite_no() { return this.sprite; }

    dig() {
        if (this.state != State.NORMAL) 
            return;
        this.state = State.BREAKING;
    }

    update () {
        if (!this.anime_update()) {
            this.state = next_state_table.get(this.state);
            this.anime_index = 0;
            this.frame_count = 0;
        }
    }

    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;
    }
}
import {drawSprite} from "./index.js"
import { EBlock } from "./Block.js"

const MAP_ELEM_SIZE = 8;

const Elem = {
    NONE: 0,
    BLOCK: 1,
    LADDER: 2,
    BAR: 3,
    ROCK: 4,
}

class ENone {
    can_go_through() { return true; }
    can_up() { return false; }
    can_stand_on() { return false; }
    can_hang() { return false; }
    sprite_no() { return 0; }
    dig() {}
    update() {}
}

class ELadder {
    can_go_through() { return true; }
    can_up() { return true; }
    can_stand_on() { return true; }
    can_hang() { return true; }
    sprite_no() { return 1; }
    dig() {}
    update() {}
}

class EBar {
    can_go_through() { return true; }
    can_up() { return false; }
    can_stand_on() { return false; }
    can_hang() { return true; }
    sprite_no() { return 3; }
    dig() {}
    update() {}
}

class ERock {
    can_go_through() { return false; }
    can_up() { return false; }
    can_stand_on() { return true; }
    can_hang() { return false; }
    sprite_no() { return 22; }
    dig() {}
    update() {}
}


function createElem(id) {
    switch(id) {
    case Elem.NONE: return new ENone();
    case Elem.BLOCK: return new EBlock();
    case Elem.LADDER: return new ELadder();
    case Elem.BAR: return new EBar();
    case Elem.ROCK: return new ERock();
    }
}

function createMap(m) {
    let dat = [];
    for (let i = 0; i < m.length; i++) {
        dat[i] = createElem(m[i]);
    }
    return dat;
}

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

    update() {
        for (let o of this.data) {
            o.update();
        }
    }

    get_obj(sx,sy) {
        let x = Math.floor(sx/MAP_ELEM_SIZE);
        let y = Math.floor(sy/MAP_ELEM_SIZE);
        return this.data[x + y*this.w];
    }

    canGoThrough(x,y) {
        return this.get_obj(x,y).can_go_through();
    }

    canUp(x,y) {
        return this.get_obj(x,y).can_up();
    }

    canStandOn(x,y) {
        return this.get_obj(x,y).can_stand_on();
    }

    canHang(x,y) {
        return this.get_obj(x,y).can_hang();
    }

    isOnLadder(x,y) {
        return this.get_obj(x,y) instanceof ELadder;
    }

    isOnBar(x,y) {
        return this.get_obj(x,y) instanceof EBar;
    }

    dig(x, y) {
        this.get_obj(x,y).dig();
    }

    draw() {
        for (let y = 0; y < this.h; y++) {
            for (let x = 0; x < this.w; x++) {
               let sno = this.data[x+y*this.w].sprite_no();
               drawSprite(sno, x*MAP_ELEM_SIZE, y*MAP_ELEM_SIZE);
            }
        }
    }
}

マップデータ管理部の再構築

「ロードランナーの作り方その2」では、マップデータは数値の一次元配列で定義していました。

それは今回も変わりませんが、そのデータをclass Worldで受け取った後に、管理する内部情報としては、数値の配列そのままではなく、オブジェクトの配列に変換しています。

const map1 = [
    0,0,0,0,0,0,0,0,0,0,
    1,0,3,0,0,0,0,0,0,1,
    1,2,0,1,1,1,1,1,2,1,
    1,2,0,1,0,0,0,1,2,1,
    1,2,0,1,1,1,1,1,2,1,
    1,2,0,1,1,1,1,1,1,1,
    1,2,0,3,3,3,3,0,0,1,
    1,2,0,0,0,0,0,1,1,1,
    1,2,0,0,0,0,0,0,0,1,
    4,4,4,4,4,4,4,4,4,4,
]

マップデータ map1 が数値の配列はそのままです。面データはこの形式で管理します。これをindex.jsで、Worldオブジェクトの生成時にコンストラクタに渡します。

let map = new World(10, 10, map1);

class Worldでは、外部から渡されたマップデータをもとに、内部ではオブジェクトの配列に変換します。

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

内部のオブジェクトの配列生成は、別途createMap() 関数を定義しています。

function createMap(m) {
    let dat = [];
    for (let i = 0; i < m.length; i++) {
        dat[i] = createElem(m[i]);
    }
    return dat;
}

個別のオブジェクトの生成は、createElem() 関数を作って、数値ごとに対応するオブジェクトをnewして返します。

function createElem(id) {
    switch(id) {
    case Elem.NONE: return new ENone();
    case Elem.BLOCK: return new EBlock();
    case Elem.LADDER: return new ELadder();
    case Elem.BAR: return new EBar();
    case Elem.ROCK: return new ERock();
    }
}

マップ管理と当たり判定の設計変更

前回の記事のデモの実装では、CharaオブジェクトとWorldオブジェクトの関係は、下記の図に示すものでした。

Worldがマップデータを隠蔽することで、データ構造が変わってもCharaは実装を変更しなくて済むように工夫していました。

今回のCharaとWorldオブジェクトの関係は、下記の図に示す通りです。

前回とは異なり、Worldが持つマップデータは数値の配列ではなく、数値に対応したオブジェクトの配列です。また、Worldは、物体の種類に対する当たり判定のメソッドではなく、canGoThrough() などの別の切り口のメソッドを用意しています。

Charaは移動する際に、画面上の構成物を調べて、先に行けるのか判断する必要があります。前回の記事の実装では下記のとおりでした。

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

isHitWall()というメソッドをWorldは用意していて、それを使っていました。Charaは「壁」という物体を意識して行動を決めます。

今回は、作り方を変えてcheck_move_right()は下記の実装にしています。

    check_move_right() {
        if (!this.map.canGoThrough(this.x+this.w+1, this.y))
            return;
    ・・・
    }

isHitWall() は、canGoThrough() に置き換わっています。canGoThroughは名前の通り、物体の種類ではなく「通れるか通れないか」の結果を返します。

この切り口だと、画面上に新しい種類の構成物が増えた場合でも、Charaの方の実装を変更しなくて済むようになります。

ただし、もとの設計にもメリットがあります。Charaが何かしらの能力を得て「壁を通り抜けられる」とした場合、Chara側で物体の種類を見る方が設計としては素直です。

作りたいゲームの仕様によって単純なオブジェクトの関係の設計も変わります。どう作れば一番シンプルにできるのかを考えるのが、プログラミングの一番楽しいところです。

自動修復するブロックの実現

class Worldの中で管理するマップデータを、単純な数値の配列ではなく、オブジェクトの配列にしたのは、自動修復するブロックを実現するためでもあります。

自動修復するブロックは下記の4つの状態を持ちます。

class Blockの中で、以下のように状態とその状態に対応するアニメーションテーブルを定義しています。

const State = {
    NORMAL: 'NORMAL',
    BREAKING: 'BREAKING',
    RECOVER: 'RECOVER',
    BROKEN: 'BROKEN',
}

const anime_table = {
    NORMAL: { frames: [16], frame_interval: 1},
    BREAKING: { frames: [17,18,19,20], frame_interval: 20 },
    RECOVER: { frames: [19,18,17], frame_interval: 60 },
    BROKEN: { frames: [20], frame_interval: 60*BLOCK_RECOVER_SEC },
};

また、状態の遷移は下記のとおりです。

概念は上記に示すとおりですが、プログラム上は「完全に壊れた」「修復完了」の判断は特別何かしているわけではありません。時間の経過だけが条件です。anime_table でのそれぞれの frame_interval 分の時間が経ったら次に行きます。

ユーザのアクションがなければ、ブロックはNORMAL状態のままです。dig() メソッドが呼ばれた時に、BREAKING に状態遷移します。

NORMAL -> BREAKING以外は、遷移先は決まりきっているので、下記のようにテーブルで定義しました。

const next_state_table = new Map([
    [State.NORMAL, State.NORMAL],
    [State.BREAKING, State.BROKEN],
    [State.BROKEN, State.RECOVER],
    [State.RECOVER, State.NORMAL],
]);

Blockは一定周期でupdate()が呼ばれるので、その中でこのテーブルを見て次の状態を決めています。

NORMAL状態はユーザのアクションがなければ、NORMALのままなので、実装上の工夫で、テーブルにはNORMAL状態の次の遷移状態をNORMALと定義しています。そうすことで、NORMALを特別扱いするようなコードを書かなくても済みます。

ブロックの壊れている状態と当たり判定

ブロックは、通常の状態は通れませんが、壊れている時は通り抜けられます。

ベタな実装だと、Chara.jsが直接ブロックの状態を見て、通れるか通れないかを判断する形になります。

そうではなくて、ブロックそのものに通れるか通れないかを判断してもらえれば、Charaはブロックの状態の定義を知る必要はありません。下記図に示すとおりです。

Blockの can_go_through() の実装を見てみましょう。

    can_go_through() { return this.state != State.NORMAL; }

シンプルにNORMAL状態でなければ通れないとしています。

今回のこの構成の利点は、仮にオブジェクトの種類やブロックの状態が増えても、Chara側の実装に影響を与えないことです。

例えば、ドア (ロックされている/ロックされていない)という新しいオブジェクトを追加したとしても、Charaは指定した座標のオブジェクトがドアであることを知る必要すらなく、canGoThrough() の結果をもらえればいいので、実装はまったく変える必要がありません。

次回は

ロードランナーのメインのギミックである「自動修復するブロック」まで作りました。

次回から、ゲームの要素として大事な「敵キャラ」を実装していきたいと思います。


コメント

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