押しものパズルの作り方 – 倉庫番風のパズルを作ってみよう(前編)

ゲーム

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

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

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

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

倉庫番とは何か?

1982年に発売された古典的なパズルゲームです。

倉庫番(ウィキペディア画像)

プレイヤーは倉庫にある荷物を所定の場所に移動させます。箱を押すことはできますが、引くことはできません。一手間違った場所に箱を押してしまうと、もうにっちもさっちもいかなくなります。

この記事で作るもの

倉庫番のゲームの基本ルールを実装したデモを作ります。とりあえず動かしてみてください。
下記のデモでは、黄色の矩形が自分。茶色の矩形が箱。○が所定場所。
i … 上、m … 下、j … 左、l … 右
r … リセット

ソースコード

<html>
    <center>
        <canvas id="gameCanvas" width="640" height="480" style="border:1px solid #000000; background-color: #000;"></canvas>
    <script type="text/javascript" src="index.js"></script>
</html>
const canvas = document.getElementById('gameCanvas');
const context = canvas.getContext('2d');

const SCREEN_W = 20
const SCREEN_H = 15
const BLOCK_SIZE = 32

const Const = {
    WALL: 1,
    BOX: 2,
    POINT: 3,
    PLAYER: 4,
}

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

class Obj {
    constructor(x,y) {
        this.x = x;
        this.y = y;
    }
}

let player = new Obj(0,0);
let box_list = [];

// 入力ハンドラー
document.addEventListener('keydown', keyDownHandler, false);

function keyDownHandler(event) {
    if (event.key === 'i') { // Up
        movePlayer(0, -1);
    } 

    if (event.key === 'm') { // Down
        movePlayer(0, 1);
    }

    if (event.key === 'j') { // Left
        movePlayer(-1, 0);
    }

    if (event.key == 'l') { // Right
        movePlayer(1, 0);
    }

    if (event.key == 'r') { // Right
        init();
    }
}

function movePlayer(dx, dy) {
    if (hitBox(player.x+dx, player.y+dy)) {
        // 移動先に箱がある場合は、さらにその先が動けるかどうかを確認する
        if (! hitObj(player.x+dx+dx, player.y+dy+dy, Const.WALL)
            && ! hitBox(player.x+dx+dx, player.y+dy+dy)) 
        {
            // 動ける場合は箱を移動させて自分も動く
            moveBox(player.x+dx, player.y+dy, player.x+dx+dx, player.y+dy+dy);
            player.x += dx;
            player.y += dy;
        }
    }
    else if (!hitObj(player.x+dx, player.y+dy, Const.WALL)) {
        player.x += dx;
        player.y += dy;
    }
}

function hitObj(x, y, id) {
    return id==map[x + y *SCREEN_W]
}

function hitBox(x, y) {
    for (item of box_list) {
        if (item.x == x && item.y == y) 
            return true;
    }
    return false;
}

function moveBox(sx, sy, tx, ty) {
    for (item of box_list) {
        if (item.x == sx && item.y == sy) {
            item.x = tx;
            item.y = ty;
        }
    }
}

function drawRect(x,y, obj) {
    context.strokeStyle = '#fff';
    context.lineWidth = 2;
    context.strokeRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    
    if (obj==1) {
        context.fillStyle = '#fff'
        context.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    }
}

function drawScreen() {
    for (j = 0; j < SCREEN_H; j++) {
        for (i = 0; i < SCREEN_W; i++) {
            obj = map[ i + j*SCREEN_W ];
            if (obj == Const.POINT) {
                drawPoint(i, j);
            } else {
                drawRect(i, j, obj);
            }
        }
    }
}

function drawPlayer() {
    context.fillStyle = 'yellow'
    context.fillRect(player.x * BLOCK_SIZE, player.y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
}

function drawBox() {
    for (const item of box_list) {
        context.fillStyle = 'brown'
        context.fillRect(item.x * BLOCK_SIZE, item.y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    }
}

function drawPoint(ix, iy) {
    const radius = 8
    x = ix * BLOCK_SIZE;
    y = iy * BLOCK_SIZE; 
    context.beginPath();
    context.arc(x+radius*2, y+radius*2, radius, 0, Math.PI * 2, false);
    context.fillStyle = 'lightblue';
    context.fill();
    context.closePath();
}

function isGameClear() {
    for (const item of box_list) {
        if (map[item.x + item.y * SCREEN_W] != Const.POINT) {
            return false;
        }
    }
    return true;
}

function init() {
    box_list = [];
    for (y = 0; y < SCREEN_H; y++) {
        for (x = 0; x < SCREEN_W; x++) {
            obj = map[x + y*SCREEN_W];
            switch (obj) {
            case Const.PLAYER:
                player.x = x;
                player.y = y;
                break;
            case Const.BOX:
                box_list.push(new Obj(x,y));
                break;
            }
        }
    }
}

function draw_text_center(text, color='#fff') {
    x = BLOCK_SIZE;
    y = canvas.height/2 - 40;
    context.fillStyle = 'black';
    context.fillRect(x, y, canvas.width-BLOCK_SIZE*2, 60);

    context.fillStyle = color;
    context.font = '20px Consolas';
    context.textAlign = 'left';
    text_w = context.measureText(text).width;
    context.fillText(text, canvas.width/2-text_w/2, canvas.height/2);
}

function update() {
    context.clearRect(0, 0, canvas.width, canvas.height);
    drawScreen();
    drawBox();
    drawPlayer();
    if (isGameClear()) {
        draw_text_center("Game Clear!");
    }
}

init();

setInterval(update, 10);


データの持ち方

プログラムにおいてデータの持ち方は最重要検討項目です。データ構造によってアルゴリズムが決まってきます。

このデモの実装では以下の種類のデータを持つことにします。

① マップデータ (20x15の大きさの配列)
② プレイヤーの座標 (x, y)
③ 荷物の座標(x,y) のリスト

① マップデータ (20x15の大きさの配列)

実装では扱いやすさの理由で1次元配列で定義しています。これは面のデータです。

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

この中の数字は、1: 壁、2: 荷物、3: 収納場所、4: プレイヤー初期配置、になっています。

デモではステージがひとつのみなので、20 x 15の一面のみですが、複数のステージを追加した場合は、これを2次元配列などして管理します。

各ステージのデータを手で編集するのはしんどいので、本格的にゲームを作るなら制作用ツールを作る必要があるでしょう。

② プレイヤーの座標 (x, y)

以下が実装です。Objクラスは(x,y)座標を納めるために定義しています。荷物の座標と収納場所の座標でも同じものを使います。

class Obj {
    constructor(x,y) {
        this.x = x;
        this.y = y;
    }
}

let player = new Obj(0,0);

③ 荷物の座標(x,y) のリスト

ソースコードとしては下記です。

let box_list = [];

データの初期化

map データは、壁の配置と収納場所は固定ですが、荷物の場所、プレイヤーの場所はゲームが進行したら位置が変わります。

ゲームが始まる前に、map から初期配置の位置データを取り出して、player、box_list に座標データを入れる必要があります。

以下がその実装です。

const Const = {
    WALL: 1,
    BOX: 2,
    POINT: 3,
    PLAYER: 4,
}
・・・
function init() {
    box_list = [];
    point_list = [];
    for (y = 0; y < SCREEN_H; y++) {
        for (x = 0; x < SCREEN_W; x++) {
            obj = map[x + y*SCREEN_W];
            switch (obj) {
            case Const.PLAYER:
                player.x = x;
                player.y = y;
                break;
            case Const.BOX:
                box_list.push(new Obj(x,y));
                break;
            }
        }
    }
}

obj = map[x + y*SCREEN_W] で map からデータを取り出して、種類を判別して、座標の情報を格納しています。



基本ルール

基本ルールは、プレイヤーの操作の観点からは、下記のパターンだけです。

プレイヤーを上下左右のどれかの方向に動かす時に、

(1) ひとマス先に荷物があるかどうか?
 (1-1) 荷物があった場合、さらのひとマス先に壁か荷物があるか?
(2) 箱がなかったら、ひとマス先に壁があるかどうか?

というのをチェックします。

ソースコードでは下記の関数で実装しています。この関数は上下左右全ての方向に対応しています。

function movePlayer(dx, dy) {
    if (hitBox(player.x+dx, player.y+dy)) {
        // 移動先に荷物がある場合は、さらにその先が動けるかどうかを確認する
        if (! hitObj(player.x+dx+dx, player.y+dy+dy, Const.WALL)
            && ! hitBox(player.x+dx+dx, player.y+dy+dy)) 
        {
            // 動ける場合は荷物を移動させて自分も動く
            moveBox(player.x+dx, player.y+dy, player.x+dx+dx, player.y+dy+dy);
            player.x += dx;
            player.y += dy;
        }
    }
    else if (!hitObj(player.x+dx, player.y+dy, Const.WALL)) {
        player.x += dx;
        player.y += dy;
    }
}

荷物を動かす

荷物を動かせるどうかはすでに、movePlayer() 関数の中で判断しているので、動かせる場合はただ単に、一つ先のマスに移動させるだけです。

下記の関数がその実装です。 sx, sy が移動元の座標、tx, ty が移動先の座標です。

function moveBox(sx, sy, tx, ty) {
    for (item of box_list) {
        if (item.x == sx && item.y == sy) {
            item.x = tx;
            item.y = ty;
        }
    }
}

ゲームクリアの判断

ゲームクリアの条件は「すべての荷物が収納場所に配置されているか」です。

これをそのまま実装すればよいので、とてもシンプルです。

function isGameClear() {
    for (const item of box_list) {
        if (map[item.x + item.y * SCREEN_W] != Const.POINT) {
            return false;
        }
    }
    return true;
}

やっているのは、box_listにあるすべての荷物の座標をなめて行って、mapのその位置が収納場所がどうかを確認しています。収納場所は、Const.POINTで定義していて、中身は数字の3です。

次回は

今回の実装は、倉庫番の全ルールは実装しているので、見た目を気にしなければ、これでも遊べます。(実際のパズルの要素として最重要な、面のデータがあれば)

とはいえ、画面に表示するのが矩形や丸だけでは雰囲気が出ないので、次回はちゃんとしたドット絵を表示させて、ゲームの完成としたいと思います。


コメント

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