トンネルを掘る秀逸パズルゲームの作り方~JavaScriptでゲームを作ろう

ゲーム

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

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

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

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

どういうゲームを作るのか

実際のゲームは下記リンク先にあるので遊んでみてください。

ソースコード

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

ソースコードの構成は下記のとおりです。

.
├── Chara.js
├── GObject.js
├── Rock.js
├── SpriteSheet.js
├── UserInput.js
├── World.js
├── assets
│   ├── spritesheet.png
│   ├── stages.json
│   └── title.png
├── index.html
└── index.js

SpriteSheet.jsはスプライトシートを管理するクラスです。UserInput.jsはキーボードとゲームパッドをを統一的に扱う入力イベント用のクラスです。この2つについては下記の記事を参照してください。

ドット絵キャラクターのアニメーションのやり方については下記記事を参照してください。

この記事で解説すること

この記事では以下の実装について解説します。

  • 主なゲームデータの管理(マップデータ、落ちる岩、プレイヤー)
  • 「トンネルを掘る」方法
  • 落ちる岩の実現方法
  • 画面遷移のアニメーション

主なゲームデータ管理

マップデータ

このゲームのマップは20×13のマスで構成されています。マップ上の構成物は下記のとおりです。

マップ情報は後でステージを増やしやすいように、JSONファイルで定義しています。マップデータは20×13を一次元配列に展開したもので持ちます。下記ファイルの”data”の部分です。

{
    "maps": [
        {
            "w": 20,
            "h": 13,
            "data": 
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 4, 2, 2, 7, 2, 2, 2, 2, 5, 2, 2, 4, 2, 2, 2, 2, 3, 2, 2, 2, 4, 2, 2, 2, 5, 2, 2, 3, 2, 2, 3, 4, 2, 2, 2, 2, 3, 2, 2, 3, 3, 3, 3, 2, 2, 2, 2, 3, 3, 2, 2, 4, 2, 2, 2, 2, 3, 2, 2, 3, 4, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 4, 2, 2, 2, 2, 3, 3, 3, 2, 4, 2, 2, 5, 3, 2, 2, 2, 2, 2, 2, 4, 2, 2, 5, 3, 5, 2, 2, 2, 4, 2, 2, 2, 2, 2, 2, 2, 2, 5, 2, 4, 2, 2, 3, 3, 2, 2, 3, 2, 4, 2, 2, 2, 2, 2, 2, 2, 2, 4, 2, 3, 3, 3, 2, 3, 2, 2, 2, 3, 4, 2, 2, 2, 2, 2, 2, 2, 2, 4, 2, 2, 2, 2, 2, 5, 2, 2, 2, 2, 4, 3, 2, 2, 2, 2, 2, 2, 2, 4, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 2, 2, 2, 2, 2, 2, 4, 2, 2, 2, 2, 2, 2, 2, 2, 5, 2, 2, 2, 3, 3, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
        }
    ]
}

“data”の中の数値の意味はWorld.jsで定義しています。

const Elem = {
    NONE: 0,
    BLOCK: 1,
    SOIL: 2,
    ROCK: 3,
    VINE: 4,
    GOLD: 5,
    HOLE: 6,
    GOAL: 7,
    PLAYER: 9,
}

JSONファイルはindex.jsでロードし、その中のmaps情報はJavaScriptオブジェクトとして保持します。Worldクラスのインスタンス生成時に、一つのステージのマップデータ渡されます。

・・・
// load stage data
const res = await fetch("./assets/stages.json");
if (res.ok) {
    const data = await res.json();
    stages = data.maps;
}
・・・
        world = new World(SCREEN_W, SCREEN_H, stages[0].data);
・・・

World.jsは渡されたマップデータをもとに内部で管理するデータ形式に変換します。

プレイヤーはマップ上を動きますが、マップ上のどの位置にどの構成物があるのかを知る必要があります。このデータの管理方法はいくつか考えられますが、今回は分かりやすく「動くもの」と「動かないもの」で分けています。

空間、土、つた、宝箱、ゴールド、トンネル動かないものオブジェクトの配列で管理
(20×13を展開した一次元配列)
岩(複数ある)動くもの座標情報を持つオブジェクトのリストとして管理

今回のようなシンプルなゲームでもマップデータの持ち方はいろいろ考えられます。別の機会に他のやり方もトライして比較してみたいと思います。

実装では、Worldクラスのthis.map, this.player, this.rock_list がそれに当たります。

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

動かないものの定義

マップ上の動かない物体は、下記のようにプロパティを持つオブジェクトとして定義します。

const ENone = {
    can_go_through: true,
    can_up: false,
    can_stand_on: false,
    can_pick_up: false,
    can_dig: false,
    sprite_no: Elem.NONE,
}

ENone以外に、ESoil, EBlock, EVine, EGold, EHole, EGoal があります。これらのオブジェクトのプロパティは、プレイヤーがどういう行動を取れるのか?の情報になります。

動くもの:プレイヤーと岩

プレイヤー・キャラクターがステージ上で動くのはもちろん、岩は下にトンネルが掘られると落ちます。座標を持ち、座標を移動するという意味で共通性があります。

プレイヤーはChara.jsファイル内のCharaクラスで定義しています。岩はRock.jsファイル内のでRockクラスで定義しています。この2つのクラスの共通部分を括り出して親クラスGObjectを作成しています。

「トンネルを掘る」方法

プレイヤーは土の中のを掘り進めることができ、その跡がトンネルとして残ります。マップ上のひとマスの要素としては、下記のESoilとEHoleオブジェクトとして定義しています。

・・・
const ESoil = {
    can_go_through: true,
    can_up: false,
    can_stand_on: true,
    can_pick_up: false,
    can_dig: true,
    sprite_no: Elem.SOIL,
}
・・・
const EHole = {
    can_go_through: true,
    can_up: false,
    can_stand_on: false,
    can_pick_up: false,
    can_dig: false,
    sprite_no: Elem.HOLE,
}

「トンネルを掘る」の実行はとても簡単です。プレイヤーが通った座標のオブジェクトを置き換えるだけになります。具体的な処理はWorldクラスの下記関数です。

    dig(x,y) {
        const p = map_pos({x,y});
        this.map[p.x + p.y*this.w] = EHole;
    }

なお、プレイヤーが通れる場所は土だけではないので、この関数を呼ぶ前にcan_digかどうかを見ています。

落ちる岩の実現方法

岩はマップ上のその他の構成物とは切り離して管理しています。

初期の配置は、JSONファイルから読んだステージのマップデータによって決まります。Worldクラスのコンストラクタで渡されたマップデータから岩がある座標情報を抽出して、Rockオブジェクトを作成します。

下記のthis.rock_listが抽出されたRockオブジェクトが格納されている配列です。

export class World {
    constructor(w,h, data) {
     ・・・
        this.rock_list = this.createRock(w,h,data);
     ・・・
    }
・・・
    createRock(w,h,data) {
        let list = [];
        for (let y = 0; y < h; y++) {
            for (let x = 0; x < w; x++) {
                if (data[x + y*w] == Elem.ROCK)
                    list.push(Rock.create(x*MAP_ELEM_SIZE, y*MAP_ELEM_SIZE, this));
            }
        }
        return list;
    }
・・・

このゲームの世界は30ミリ秒ごとに更新されます。その度にWorldのupdate()関数が呼ばれます。このupdate()関数の中で、rock_listにある全てのRockオブジェクトのupdate()が呼ばれます。

Rockオブジェクトは自身のupdate()が呼ばれるたびに、落下するかどうかをチェックします。

    can_fall() {
        if (this.world.canStandOn(this.x, this.y+this.h+1))
            return false;
        return true;
    }

ここでは自身の下のマスを調べて、canStandOn() かどうか調べています。(Charaオブジェクトと同じ感じでチェックします)。

canStandOn()はWorldクラスに定義されていて下記のとおりです。

    canStandOn(x,y) {
        if (this.isHitRock(x,y))
            return true;
        if (this.isHitPlayer(x,y))
            return true;
        return this.get_obj(x,y).can_stand_on;
    }

条件は、下のマスが岩ではないとき、かつプレイヤーでないとき。それ以外は can_stand_on の属性があるオブジェクトの時です。実際にどのオブジェクトの can_stand_on がtrueになっているかはソースを調べてみてください。

トンネルが掘られると落下する

さきほどの説明で、トンネルを掘ることは、プレイヤーの通った場所が EHole オブジェクトに置き換わると説明しました。

EHoleの定義は下記のとおりです。

const EHole = {
    can_go_through: true,
    can_up: false,
    can_stand_on: false,
    can_pick_up: false,
    can_dig: false,
    sprite_no: Elem.HOLE,
}

can_stand_onはfalseになっています。つまり、EHoleになった後に、Rockはこの位置はcall_fall()関数で落下できると判断します。そのあと、State.FALL に移り落下します。

画面遷移のアニメーション

このゲームでは画面遷移の時に、少し趣向を凝らして、円形のクリップが縮小拡大するエフェクトを追加しています。レトロなゲームによくある演出です。

仕組みは下記図に示すとおりです。

まず、エフェクトがあるなしに関わらずキャンバスを2面持っています。

実際のゲーム画面の大きさは160×104ドットとかなり小さいものです。canvasBgがそれに当たりますが、属性を非表示にして見えないようにしています。

canvasBgは小さすぎて遊べないのでブラウザ上に4倍に拡大して表示しています。拡大表示用のキャンバスがgameCanvasです。

ゲーム進行中は、30ミリ秒ごとにcavasBg全体をgameCanvasに拡大描画しています(drawImage()関数を使用)。この際に、描画する領域を円形にクリップすることで、円の内側だけ表示させるエフェクトが得られます。

円形にクリップしてからgameCanvasに拡大してdrawImage()する部分はindex.jsのclip_circle()関数です。この関数の引数 radius を変えることで拡大・縮小しています。

function clip_circle(radius) {
    context.save();

    context.beginPath();
    const cX = 320;
    const cY = 200;
    context.arc(cX, cY, radius, 0, Math.PI * 2);
    context.clip();

    context.drawImage(canvas_bg, 0, 0, canvas_bg.width, canvas_bg.height, 0, 0, canvas.width, canvas.height);

    context.restore();
}

時間経過によって上記clip_circle関数を呼ぶことで円クリップの拡大・縮小アニメーションをしますが、具体的に時間経過はどうやって測るのでしょうか?

このゲーム世界は30ミリ秒に一回update()が呼ばれます。これを1フレームとします。

アニメーションが遷移するための期間をフレーム数として設定し、最大フレームになるまで、フレームが更新されるたびに、円クリップの半径を変化させていくことでアニメーションします。

このフレームカウントの仕組みはアニメーションのエフェクトに関わらず汎用的に作れるので、Animationクラスとして実現しています。

class Animation {
    constructor(frames, func) {
        this.num_of_frames = frames;
        this.frame = 0;
        this.func = func;
    }
    play(func) {
        this.frame++;
        if (this.frame > this.num_of_frames) {
            return {finished: true};
        }
        this.func(this.num_of_frames, this.frame);
        return {finished: false};
    }
}

実際にこのクラスを使用ている部分は下記のとおりです。コンストラクタに与えているanim_clip_shurink, anim_clip_enlargeは、clip_circleを呼んで拡大縮小をさせている関数です。これらの中身がどうなっているのかはソースコードを見てみてください。

・・・
    case State.TITLE:
      ・・・
        if (input.start) {
            state = State.LEAVE_TITLE; 
            trans_anim = new Animation(70, anim_clip_shurink);
        }
        break;
    case State.LEAVE_TITLE:
        if (trans_anim.play().finished) {
            state = State.INIT_STAGE;
            trans_anim = new Animation(70, anim_clip_enlarge);
        }
        break;
・・・

まとめ

さて、1980年代の名作パズルMOLE MOLE(モールモール)をオマージュしたゲームの作り方について説明してきました。実際にソースを見ればわかるように、1000行にも満たないコンパクトなゲームです。

このゲームの世界を支配する法則は最小限ですが、パズル要素としてしっかり面白いです。

一方で、オリジナルとは違いますが、8×8ドットのキャラクター、画面全体でも160×104ドットしかない小さな世界を作ることに、筆者は喜びを感じました。

筆者はシンプルでコンパクト、それでいて奥深いものが好きですが、このゲームは今のところベストの位置にいます(自分で作る楽しさにおいて)。次作に行く前に、もう少しこのゲームをいじっていたいと思っています。

コメント

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